Tree-sitter syntax highlighting for AL (Business Central) code blocks in Claude's replies

A Claude Code mod that highlights AL (Microsoft Dynamics 365 Business Central) code in Claude's replies, using the tree-sitter-al grammar.

Claude Code's built-in highlighter has no AL grammar, and plugins cannot add one to it yet. So this mod redraws the replies that contain ``` `al ``` blocks itself: the prose is drawn by Claude Code as usual, and each AL block is parsed by tree-sitter and coloured.
PATH (tested with Node 25)Everything else ships with the mod: the tree-sitter runtime (web-tree-sitter), the compiled AL grammar and its highlight query. Nothing is downloaded or compiled at run time.
The repository is its own marketplace:
claude plugin marketplace add abonckus/claude-code-al-syntax
claude plugin install al-syntax@al-syntax
To try a local checkout instead, run claude --plugin-dir ./claude-code-al-syntax.
ui.render hook on AssistantMessage looks for closed ``` `al ``` fences. A reply without one is left to Claude Code untouched.highlighter/highlight.mjs, run by node in a separate process (mods have no WebAssembly of their own). It parses the code with tree-sitter-al and answers with highlight captures. The grammar only parses whole objects, so a snippet of bare statements or members is parsed again inside a stub object, and the cleanest parse wins.highlighter/highlight.mjs is a plain command, usable by any tool that can run one:
node highlighter/highlight.mjs < MyCodeunit.Codeunit.al
It reads AL source on stdin and writes one JSON array of [text, capture] spans to stdout, where capture is a tree-sitter highlight name (keyword.control, function.definition, comment.line…) or null. The spans join back into the input exactly. A non-zero exit means it could not highlight, with the reason on stderr.
For example, a previewer that takes external highlighter commands can be pointed at it for the al language.
`al ``` blocks are coloured, and only once their closing fence has arrived; while a block is still streaming it shows as plain text.node cannot be run, AL blocks are drawn plain and a toast says why, once per session. A reply large enough to pass Claude Code's drawing limit has its later AL blocks drawn plain.The grammar files come from a tree-sitter-al release, and the query must match the .wasm of the same tag:
gh release download <tag> -R SShadowS/tree-sitter-al -p tree-sitter-al.wasm -D highlighter/grammar --clobber
gh api "repos/SShadowS/tree-sitter-al/contents/queries/highlights.scm?ref=<tag>" --jq .content | base64 -d > highlighter/grammar/highlights.scm
web-tree-sitter is vendored from npm (web-tree-sitter.js, renamed to .mjs, and web-tree-sitter.wasm). Keep its version compatible with the grammar's tree-sitter ABI.
claude plugin validate .
claude plugin test .
hooks/fences.ts splits a reply into prose and AL blocks, and hooks/theme.ts maps captures to colours, each with its own *.test.ts. hooks/render.test.ts mounts a reply and checks the drawn tree, the fallback without Node, and that other replies are left alone.
MIT. Third-party components are listed in THIRD_PARTY_NOTICES.md.
hooks/register.tsx 75 lines1import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
2
3import { hasAl, split } from './fences'
4import { lines } from './theme'
5import type { Span } from './theme'
6
7const MAX = 10000 // Markdown and Code element cap
8const TREE_BUDGET = 80_000 // serialized characters of highlighted lines in one reply, inside the engine's 100,000
9
10// Successes only, keyed by source: a reply redraws often while it streams.
11// ponytail: module cache, lost on reload; a redraw highlights again
12const cache = new Map<string, Span[]>()
13let warned = false
14
15const SCRIPT = 'highlighter/highlight.mjs'
16const RUN = { timeoutMs: 15_000 }
17
18// `run` starts the highlighter on `code`: each caller spells its own `$.process.run`.
19const highlight = async (code: string, run: () => Promise<ProcessRunResult>): Promise<Span[] | { error: string }> => {
20 const hit = cache.get(code)
21 if (hit) return hit
22 try {
23 const ran = await run()
24 if (ran.exitCode !== 0) return { error: ran.stderr.trim().split('\n').find(Boolean) ?? `exit ${ran.exitCode}` }
25 const spans = JSON.parse(ran.stdout) as Span[]
26 cache.set(code, spans)
27 return spans
28 } catch (err) {
29 return { error: String(err) }
30 }
31}
32
33export const register: Register = on => {
34 // Redraws a reply that holds ```al fences: prose as the engine's Markdown, AL as
35 // tree-sitter colours. Every other reply is left to the engine (and other mods).
36 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
37 const parts = split(e.props.text)
38 if (!hasAl(parts)) return next(e)
39 const { Box, Text, Markdown, Code } = $.ui.resolve(e)
40 let size = 0
41
42 const block = async (code: string) => {
43 const spans = await highlight(code, () => $.process.run(['node', `${$.plugin.root}/${SCRIPT}`], { ...RUN, stdin: code }))
44 if (!Array.isArray(spans)) {
45 if (!warned) {
46 warned = true
47 $.ui.toast(`al-syntax: could not run the highlighter (${spans.error}). Is node on PATH?`, { timeoutMs: 8000 })
48 }
49 return <Code source={code.slice(0, MAX) || ' '} language="al" />
50 }
51 const rows = lines(spans).map(segs => (
52 <Text wrap="wrap">
53 {segs.length ? segs.map(s => (s.color || s.italic ? <Text color={s.color} italic={s.italic}>{s.text}</Text> : s.text)) : ' '}
54 </Text>
55 ))
56 size += JSON.stringify(rows).length
57 // Past the tree bound the engine would refuse the whole reply: draw this one plain.
58 if (size > TREE_BUDGET) return <Code source={code.slice(0, MAX) || ' '} language="al" />
59 return (
60 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
61 {rows}
62 </Box>
63 )
64 }
65
66 const drawn = []
67 for (const p of parts) drawn.push(p.kind === 'md' ? <Markdown text={p.text.slice(0, MAX)} /> : await block(p.code))
68 return (
69 <Box flexDirection="column" rowGap={1}>
70 {drawn}
71 </Box>
72 )
73 })
74}
75hooks/fences.ts 32 lines1// Splits a reply's markdown into prose and closed ```al fences. An unclosed fence
2// (a reply still streaming) stays prose until its closing line arrives.
3export type Part = { kind: 'md'; text: string } | { kind: 'al'; code: string }
4
5const OPEN = /^\s*(```|~~~)\s*al\s*$/i
6
7export const split = (text: string): Part[] => {
8 const lines = text.replace(/\r\n?/g, '\n').split('\n')
9 const out: Part[] = []
10 let prose: string[] = []
11 const flush = () => {
12 if (prose.join('').trim()) out.push({ kind: 'md', text: prose.join('\n') })
13 prose = []
14 }
15 for (let i = 0; i < lines.length; i++) {
16 const line = lines[i] ?? ''
17 const open = OPEN.exec(line)
18 const close = open && lines.findIndex((l, j) => j > i && new RegExp(`^\\s*${open[1]}\\s*$`).test(l))
19 if (!open || close === null || close < 0) {
20 prose.push(line)
21 continue
22 }
23 flush()
24 out.push({ kind: 'al', code: lines.slice(i + 1, close).join('\n') })
25 i = close
26 }
27 flush()
28 return out
29}
30
31export const hasAl = (parts: readonly Part[]) => parts.some(p => p.kind === 'al')
32hooks/theme.ts 44 lines1// Tree-sitter capture names to colours (GitHub dark), and spans split into lines.
2export type Span = [text: string, capture: string | null]
3export type Styled = { text: string; color?: string; italic?: boolean }
4
5const THEME: Record<string, Omit<Styled, 'text'>> = {
6 keyword: { color: '#ff7b72' },
7 operator: { color: '#ff7b72' },
8 function: { color: '#d2a8ff' },
9 method: { color: '#d2a8ff' },
10 type: { color: '#ffa657' },
11 module: { color: '#ffa657' },
12 string: { color: '#a5d6ff' },
13 number: { color: '#79c0ff' },
14 boolean: { color: '#79c0ff' },
15 constant: { color: '#79c0ff' },
16 property: { color: '#79c0ff' },
17 attribute: { color: '#7ee787' },
18 label: { color: '#7ee787' },
19 'variable.builtin': { color: '#ffa657' },
20 'variable.parameter': { color: '#ffa657' },
21 comment: { color: '#8b949e', italic: true },
22}
23
24// `keyword.control.conditional` tries itself, then `keyword.control`, then `keyword`.
25export const style = (capture: string | null): Omit<Styled, 'text'> => {
26 for (let name = capture ?? ''; name; name = name.slice(0, Math.max(0, name.lastIndexOf('.')))) {
27 const hit = THEME[name]
28 if (hit) return hit
29 }
30 return {}
31}
32
33export const lines = (spans: readonly Span[]): Styled[][] => {
34 const out: Styled[][] = [[]]
35 for (const [text, capture] of spans) {
36 text.split('\n').forEach((piece, i) => {
37 if (i > 0) out.push([])
38 if (piece) out[out.length - 1]?.push({ text: piece, ...style(capture) })
39 })
40 }
41 if (out.length > 1 && out[out.length - 1]?.length === 0) out.pop()
42 return out
43}
44