SLOPSHOPPER

al-syntax

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

newrowstoastprocess
v0.1.2MITupdated 2026-10-05abonckus/claude-code-al-syntax
A shopper browsing a rack in a slop shop
README

al-syntax

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

An AL table extension and page extension highlighted in a Claude Code reply

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.

Requirements

  • Claude Code with mod (function hook) support
  • Node.js on your 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.

Install

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.

How it works

  1. A ui.render hook on AssistantMessage looks for closed ``` `al ``` fences. A reply without one is left to Claude Code untouched.
  2. Each AL block is sent to 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.
  3. The mod colours the captures (a GitHub-dark palette) and draws the block in a frame. Results are cached per block, so a reply that redraws while it streams is parsed once per finished block.

Using the highlighter elsewhere

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.

Limitations

  • Fenced blocks only. Only ``` `al ``` blocks are coloured, and only once their closing fence has arrived; while a block is still streaming it shows as plain text.
  • One mod per reply. Claude Code draws a reply with the first mod that redraws it. A reply with AL code is drawn by this mod, so another mod that also redraws replies (for example one that turns file paths into links) does not apply to that reply.
  • Dark palette. The colours are chosen for a dark terminal theme.
  • Fallback. If 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 reply bullet. A redrawn reply loses its leading bullet.

Updating the grammar

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.

Development

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.

License

MIT. Third-party components are listed in THIRD_PARTY_NOTICES.md.

Source 3 files
hooks/register.tsx 75 lines
1import 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}
75
hooks/fences.ts 32 lines
1// 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')
32
hooks/theme.ts 44 lines
1// 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