Editor Support
The tree-sitter grammar, the Zed extension, and what still highlights slightly wrong.
Psy ships a real tree-sitter grammar and a Zed extension built on it, in two repositories:
tree-sitter/ the grammar
grammar.js
src/scanner.c the external scanner
queries/ the canonical queries
test/corpus/ the corpus suite
zed/ the Zed extension
extension.toml
languages/psy/They are separate because Zed fetches grammars over git and expects an extension
repository whose root holds extension.toml. Every extension in Zed's registry is
arranged this way, including the ones whose author owns both halves.
The grammar
Psy is indentation-oriented and its strings are usually unquoted, so most lexical
decisions live in an external scanner (src/scanner.c) that mirrors the reference
lexer:
- Layout.
_newline,_indent,_body_indentand_dedentare produced from indentation. Blank lines and comment-only lines never affect layout, which is what lets multiline strings contain blank lines. - Block kind inference. A block is an object, a list or a multiline string
depending on its first non-composition operation, and raw text runs to the end
of the block. That is why
- not a list iteminside aprompt:block highlights as prose. - Identifier resolution. The text after
:is a structured value only when the whole of it forms one; otherwise it is an unquoted string.
npm run generate
npm testBoth need a C toolchain. No other checkout does.
The grammar is checked against reality: every .psy file in the project — the
whole specification, the build definitions, the examples and the fixtures —
parses with zero error nodes.
cd tree-sitter
npx tree-sitter parse --quiet --stat '../spec/**/*.psy'Highlight queries
tree-sitter/queries/highlights.scm is canonical. Zed loads queries from the
extension, so zed keeps a byte-identical copy and pulls it in — the consumer
pulls, rather than the grammar pushing into a repository it does not own:
cd zed
npm run queries:sync # copy from ../tree-sitter/queries
npm run verify # fail if they have driftedA test asserts the two stay in sync, and that every reserved word the compiler knows about is covered by the query.
Now, a bare identifier is a reference when it names a symbol and a string otherwise — which is a question the grammar structurally cannot answer, because it requires the module namespace. So the queries fall back on Psy's naming conventions as the signal:
((value_identifier) @string
(#match? @string "^[a-z_]"))
((value_identifier) @type
(#match? @type "^[A-Z]"))Which is why name: example highlights example as a string and
use: [ReadPsySpec] highlights ReadPsySpec as a type. It is a heuristic. It is
right approximately always, because the naming conventions exist precisely to keep
those two apart, and it is wrong the moment you name a spec lowercase.
Installing the Zed extension
Zed fetches grammars over git, so the extension manifest must name a real commit:
cd zed
npm run prepare-grammar # from the grammar's origin and HEAD
npm run prepare-grammar:local # from the sibling checkout, over file://Those fill repository and rev under [grammars.psy] in extension.toml,
pointing at tree-sitter — never at the extension's own repository.
Do it after committing the grammar. Zed fetches a revision, not your working tree, and this will catch you at least once.
Then:
- Open the command palette and run zed: install dev extension.
- Choose the
zeddirectory.
Zed clones the grammar at the pinned revision into zed/grammars/, which is build
output and is gitignored, and builds it.
What the extension provides
| File | Provides |
|---|---|
languages/psy/config.toml | file association, // comments, 4-space indent, bracket pairs, indent patterns |
languages/psy/highlights.scm | syntax highlighting |
languages/psy/indents.scm | indent scopes for auto-indent |
languages/psy/outline.scm | the outline panel: specs and constants |
languages/psy/injections.scm | nothing — Psy embeds no other languages |
Known limitations
Three, all cosmetic, all written down rather than hidden:
- A line inside a multiline string that begins with a single
/followed by a non-/character may lose that first character from the highlighted span. It does not affect parsing. - Interpolation is a separate node inside multiline blocks and quoted strings, but
an inline unquoted value is a single token — so
${...}inside one highlights as part of the string. - Two consecutive comment-only lines between a property header and its block are absorbed into the layout token rather than highlighted.
Other editors
It is an ordinary tree-sitter grammar, so anything that consumes one — Neovim,
Helix, Emacs treesit — can use it directly with the queries in
tree-sitter/queries/.
This documentation site uses a separate TextMate grammar
(docs/psy.tmLanguage.json), because Shiki does not consume tree-sitter. Two
grammars for one language is not something I am proud of, but the alternative was
no syntax highlighting in the docs.