Reference

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_indent and _dedent are 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 item inside a prompt: 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 test

Both 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 drifted

A 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:

  1. Open the command palette and run zed: install dev extension.
  2. Choose the zed directory.

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

FileProvides
languages/psy/config.tomlfile association, // comments, 4-space indent, bracket pairs, indent patterns
languages/psy/highlights.scmsyntax highlighting
languages/psy/indents.scmindent scopes for auto-indent
languages/psy/outline.scmthe outline panel: specs and constants
languages/psy/injections.scmnothing — 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.

On this page