SourceExtensions for IDEsColouring a loom

Part IX, Chapter 6

Part IX, Chapter 6: Colouring a loom

Package: packages/tree-sitter-loom/

Language: scm

Raw source

Colouring a loom

A tree is not yet colour. An editor colours a file by running queries over its tree. A query is a set of patterns written in tree-sitter's query language, a small Lisp of node names and captures. A highlights query captures nodes under names such as @markup.heading or @string, and the editor's theme gives each name a colour. An injections query captures a node whose text belongs to another language, together with the language's name, and the editor parses that text with the other language's grammar and colours it by that grammar's queries.

Loom needs both kinds. The highlights query colours Loom's own marks: the frontmatter, the headings, the arrows and tildes, the anchors and warps. The injections query does the larger part of the work. It hands each code block to the parser of its section's language and each run of prose to Markdown, so a loom reads in the editor as the languages it is written in.

The capture names follow the list nvim-treesitter keeps. Helix shares every name used here but one, and its own copy of the highlights closes the chapter.

The frontmatter's colours

The frontmatter's fences are punctuation with a special role. A field's value takes the colour of what it names. A Part or Chapter line names a title, so its value is a heading, the way the headings below it are. A Package names a file or a folder, so its value is a path, as a sink's file is. A Language names a language, so its value is a type, as a specifier's label is. Any other field's value is a string.

The patterns tell the fields apart by their keys. A Part key may carry a chapter as well, Part III, Chapter 2, so the two title keys match by the word they start with. The patterns use only a plain prefix and exact names, because Neovim reads a #match? pattern as a Vim regular expression and Helix as a Rust one, and only the plainest patterns mean the same to both. The last pattern excludes the four keys the others claim, so no value takes two colours.

The key takes a colour of its own, in a section of its own, because the name of that colour is the one Helix spells differently.

Query
(frontmatter_fence) @punctuation.special

(field
  ":" @punctuation.delimiter)

((field
  key: (key) @_key
  value: (value) @markup.heading)
  (#match? @_key "^Part"))

((field
  key: (key) @_key
  value: (value) @markup.heading)
  (#match? @_key "^Chapter"))

((field
  key: (key) @_key
  value: (value) @string.special.path)
  (#eq? @_key "Package"))

((field
  key: (key) @_key
  value: (value) @type)
  (#eq? @_key "Language"))

((field
  key: (key) @_key
  value: (value) @string)
  (#not-match? @_key "^Part")
  (#not-match? @_key "^Chapter")
  (#not-eq? @_key "Package")
  (#not-eq? @_key "Language"))

A key in Neovim

Neovim names a key of a record a property.

Query
(field
  key: (key) @property)

A key in Helix

Helix has no property colour. Its themes colour a key of a record as a member of a variable.

Query
(field
  key: (key) @variable.other.member)

Heading colours

A heading takes the heading colour of its level, from @markup.heading.1 for a single # to @markup.heading.6 for six, the way a Markdown heading does. The query reads the level off the heading marker, which holds the marks and the space after them.

Query
((heading
  (heading_marker) @_marker) @markup.heading.1
  (#eq? @_marker "# "))

((heading
  (heading_marker) @_marker) @markup.heading.2
  (#eq? @_marker "## "))

((heading
  (heading_marker) @_marker) @markup.heading.3
  (#eq? @_marker "### "))

((heading
  (heading_marker) @_marker) @markup.heading.4
  (#eq? @_marker "#### "))

((heading
  (heading_marker) @_marker) @markup.heading.5
  (#eq? @_marker "##### "))

((heading
  (heading_marker) @_marker) @markup.heading.6
  (#eq? @_marker "###### "))

Specifiers and sinks

A specifier's label is a type, the language a section is written in. Two labels are not languages but instructions to Loom: {Tangle} writes the section to a file, and {TOC} makes the section a table of contents. Those two take the directive colour. A sink names a file, so its path takes the path colour. The brackets and braces around both are brackets.

Query
(specifier
  [
    "{"
    "}"
  ] @punctuation.bracket)

(specifier
  label: (label) @type)

((specifier
  label: (label) @keyword.directive)
  (#any-of? @keyword.directive "Tangle" "tangle" "TOC" "toc"))

(sink
  [
    "["
    "]"
  ] @punctuation.bracket)

(sink
  file: (file) @string.special.path)

Arrows and tildes

An arrow turns prose into code and a tilde turns code back into prose. Both are operators on the document, and both take the operator colour.

Query
(arrow) @operator

(tilde) @operator

Anchors

An anchor's name is a label, the name of the section it draws in. Its delimiters are special punctuation, and a target, the .loom file the name lives in, takes the path colour like a sink.

Query
(anchor
  (anchor_open) @punctuation.special
  "]" @punctuation.special)

(anchor
  name: (name) @label)

(anchor
  target: (target) @string.special.path)

Warps

A warp declares a variable, so its name takes the variable colour. Its annotation is a type and its default a string, and its braces, colon, and equals sign take the punctuation and operator colours.

Query
(warp
  [
    "{{"
    "}}"
  ] @punctuation.special)

(warp
  name: (identifier) @variable)

(warp
  ":" @punctuation.delimiter)

(warp
  annotation: (annotation) @type)

(warp
  "=" @operator)

(warp
  default: (default) @string)

Notes

A note is a reader's annotation on the text, not part of the program, so the whole note takes the comment colour. Its :::[Note] marker takes the colour editors give a note inside a comment.

Query
(note) @comment

(note_marker) @comment.note

Prose is Markdown

Every run of prose is Markdown: a preamble, a prose block, and a table of contents. An editor excludes a node's children from the text it injects, and the children of a preamble or a prose block are its anchors, warps, and tilde, which Markdown should not see. A table of contents is the exception. Its part headings and chapter entries are nodes, but they are Markdown headings and list items as well, so the contents keep their children.

Query
((preamble) @injection.content
  (#set! injection.language "markdown"))

((prose) @injection.content
  (#set! injection.language "markdown"))

((toc) @injection.content
  (#set! injection.language "markdown")
  (#set! injection.include-children))

A section's language

Each code block is injected with its section's language, resolved by the rules of Part IV: the section's specifier, then its sink's extension, then the chapter's language. The sections below take the rules one at a time. Each pattern matches only where the rules before it do not apply, so a code block is injected once.

Named by a specifier

A specifier names the language outright, {Bash} or {json}. Neovim reads a language's name in lower case. Three labels are not languages: {Tangle} and {TOC} are instructions, and a {Prose} section is Markdown, which a pattern of its own injects. The negations use #not-eq?, because Helix knows no #not-any-of?.

Query
(section
  (heading
    specifier: (specifier
      label: (label) @injection.language))
  (code) @injection.content
  (#not-eq? @injection.language "Tangle")
  (#not-eq? @injection.language "tangle")
  (#not-eq? @injection.language "Prose")
  (#not-eq? @injection.language "prose"))

Named by a sink

A sink names the language by its file. Both Neovim and Helix take an @injection.filename capture and find the language the way they would for a file of that name, so hello.ts reads as TypeScript and Dockerfile as a Dockerfile. A sink sets the language when the heading has no specifier, or when the specifier is {Tangle}.

Query
(section
  (heading
    !specifier
    sink: (sink
      file: (file) @injection.filename))
  (code) @injection.content)

(section
  (heading
    specifier: (specifier
      label: (label) @_tangle)
    sink: (sink
      file: (file) @injection.filename))
  (code) @injection.content
  (#any-of? @_tangle "Tangle" "tangle"))

Named by the Package file

A {Tangle} section without a sink writes the chapter's own file, the one its Package field names, so that file's name sets the language.

Query
(document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @injection.filename))
  (section
    (heading
      specifier: (specifier
        label: (label) @_tangle)
      !sink)
    (code) @injection.content)
  (#eq? @_key "Package")
  (#any-of? @_tangle "Tangle" "tangle"))

Named by the chapter

A section with neither a specifier nor a sink takes the chapter's Language field. When the chapter names no language, Loom falls back to the project's configured language, which a query cannot read, and the code stays uncoloured.

Query
(document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @injection.language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content)
  (#eq? @_key "Language")
  (#not-eq? @injection.language "Prose")
  (#not-eq? @injection.language "prose"))

A Prose section is Markdown

A section in Prose is Markdown, whether a {Prose} specifier says so or the chapter's Language does.

Query
((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#any-of? @_language "Prose" "prose")
  (#set! injection.language "markdown"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#any-of? @_language "Prose" "prose")
  (#set! injection.language "markdown"))

Names Helix knows in lower case

Helix finds an injected language by matching the name against each language's own pattern, and those patterns are lower case and match case by case. typescript reaches TypeScript, but TypeScript, the way a chapter writes it, does not, and Helix colours that code with a grammar that is not TypeScript's. A query cannot lower a name's case, so Helix's copy of the injections names each language a loom commonly writes with a capital, and sets the name Helix knows in its place.

The two rules that read a name, the specifier and the chapter's Language, take a Helix form. Each maps the capitalised names one by one, and its general pattern, for every other name, leaves out the names the map has claimed, so no code block is injected twice. Helix reads its patterns as Rust regular expressions, and this file is Helix's alone, so its exclusions use one alternation each.

Query
(section
  (heading
    specifier: (specifier
      label: (label) @injection.language))
  (code) @injection.content
  (#not-match? @injection.language "^(Tangle|tangle|TOC|toc|Prose|prose)$")
  (#not-match? @injection.language "^(TypeScript|JavaScript|JSON|YAML|TOML|Markdown|CSS|HTML|Dockerfile|Rust|Python|Go|Lua|C|Bash)$"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "TypeScript")
  (#set! injection.language "typescript"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "JavaScript")
  (#set! injection.language "javascript"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "JSON")
  (#set! injection.language "json"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "YAML")
  (#set! injection.language "yaml"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "TOML")
  (#set! injection.language "toml"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Markdown")
  (#set! injection.language "markdown"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "CSS")
  (#set! injection.language "css"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "HTML")
  (#set! injection.language "html"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Dockerfile")
  (#set! injection.language "dockerfile"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Rust")
  (#set! injection.language "rust"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Python")
  (#set! injection.language "python"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Go")
  (#set! injection.language "go"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Lua")
  (#set! injection.language "lua"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "C")
  (#set! injection.language "c"))

((section
  (heading
    specifier: (specifier
      label: (label) @_language))
  (code) @injection.content)
  (#eq? @_language "Bash")
  (#set! injection.language "bash"))

(document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @injection.language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content)
  (#eq? @_key "Language")
  (#not-match? @injection.language "^(Prose|prose)$")
  (#not-match? @injection.language "^(TypeScript|JavaScript|JSON|YAML|TOML|Markdown|CSS|HTML|Dockerfile|Rust|Python|Go|Lua|C|Bash)$"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "TypeScript")
  (#set! injection.language "typescript"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "JavaScript")
  (#set! injection.language "javascript"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "JSON")
  (#set! injection.language "json"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "YAML")
  (#set! injection.language "yaml"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "TOML")
  (#set! injection.language "toml"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Markdown")
  (#set! injection.language "markdown"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "CSS")
  (#set! injection.language "css"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "HTML")
  (#set! injection.language "html"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Dockerfile")
  (#set! injection.language "dockerfile"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Rust")
  (#set! injection.language "rust"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Python")
  (#set! injection.language "python"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Go")
  (#set! injection.language "go"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Lua")
  (#set! injection.language "lua"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "C")
  (#set! injection.language "c"))

((document
  (frontmatter
    (field
      key: (key) @_key
      value: (value) @_language))
  (section
    (heading
      !specifier
      !sink)
    (code) @injection.content))
  (#eq? @_key "Language")
  (#eq? @_language "Bash")
  (#set! injection.language "bash"))

The highlights query for Helix

Helix reads its queries from its own runtime directory, so a Helix setup copies this file there in place of the shared highlights. It differs from them only in the colour of a key.

The injections query for Helix

Helix's copy of the injections shares the rules that work by a file's name, and takes its own form of the two that read a language's name.

Loom