This project has two languages of its own — the projection (.grind, a document as plain
text) and the generator (.rhai, a script that returns a document). Neither is worth much
if writing one means keeping a reference document open beside the editor.
This is how to get highlighting, completion and inline documentation for both. It takes about two minutes, and every claim in it was checked against the extensions as installed rather than against their marketing.
- New to the two languages:
doc/projection-guide.mdanddoc/generator-guide.md. - Already set up and looking for the vocabulary:
doc/projection-sheet.mdanddoc/generator-spec.md.
.grind |
.rhai |
|
|---|---|---|
| Syntax highlighting | ● KDL grammar | ● Rhai grammar |
| Brace matching, auto-indent, comment toggling | ● | ● |
| Syntax errors underlined as you type | ● from the KDL language server | ○ nothing published does this — §5 |
| Completion for every node / function | ● snippets, once a letter is typed | ● snippets, once a letter is typed — nothing on s., and no filtering by what s is |
| A description on the completion item | ◐ one line, behind Ctrl+Space | ◐ the engine's doc comment, as one plain-text detail line behind Ctrl+Space |
| Hover documentation on code already written | ○ with snippets alone — ● with grind lsp and its client (§7.1) |
○ the same — ● the same |
| Types and signatures | ○ the node's example in its description | ● in the snippet's description, and in full in grind.d.rhai |
| Actual validation of what you wrote | grind lint |
grind build |
Measured, not assumed (2026-10-11, VS Code with both extensions, driven headless through its
own vscode.executeCompletionItemProvider): typing s.pu offers the three push snippets and
s. alone offers none, because a snippet is matched on a word and knows nothing about s. A
snippet's description arrives as the item's detail — one plain-text line, "(Workspace
Snippet)" appended, shown when the suggestion's details are open (Ctrl+Space a second time, or
the > on the highlighted entry) — while its documentation pane holds only a preview of the
code it inserts. Completing on the dot and hover over existing code are both a language server's:
grind lsp has them, and in VS Code its client extension starts it (§7.1). And none of it reaches a .grind in an untrusted folder — §8's
first row.
The last row is not a consolation prize. An editor cannot tell you that a formula reads a
deleted sheet or that a cached total disagrees with its formula; grind lint can, and it is one
command. Set it up as a task (§6) and it is one keystroke.
Nothing to do. Open the repository root as the workspace folder and VS Code will offer the two extensions it recommends, then read the three files already checked in:
| File | What it does |
|---|---|
.vscode/extensions.json |
recommends kdl-org.kdl and rhaiscript.vscode-rhai |
.vscode/settings.json |
maps *.grind to the KDL language, and puts snippets at the top of the completion list for both |
.vscode/grind.code-snippets |
every function grind build registers — generated, and a test fails when it is stale |
.vscode/grind-projection.code-snippets |
every node of the projection's grammar, for both document types |
Install the two extensions when prompted, or:
$ code --install-extension kdl-org.kdl
$ code --install-extension rhaiscript.vscode-rhaiThen open examples/quote.grind and examples/timesheet/timesheet.rhai and type. In the first, cell
offers a cell node with its formula as a tab stop; in the second, s. then pu offers push
with what it does underneath.
One command does all of this section and §6:
$ grind init ~/work/pricingIt writes everything below — the snippets, the definition file, the settings, the tasks of §6,
the two extension recommendations — plus a starter script that builds a spreadsheet, an
.editorconfig, a .gitignore and a .gitattributes that makes git diff read an .ods. It
never overwrites a file that is already there: a settings.json of your own is kept as it is,
and the report says which line it is missing. --template sheet or --template text starts
from a hand-written .grind instead of a script, and --template none from nothing.
After upgrading grind, grind init --update rewrites the snippets and the definition file
— the vocabulary, which a newer grind may have more of — and touches nothing else; a plain
grind init says stale beside any that are out of date. doc/init.md is the design.
What follows is what it does, by hand — for an editor other than VS Code, or a project that
wants only part of it. Four commands and one setting. Say your spreadsheets and scripts live in
~/work/pricing:
$ cd ~/work/pricing
$ mkdir -p .vscode
$ grind definitions --snippets > .vscode/grind.code-snippets
$ grind definitions --projection > .vscode/grind-projection.code-snippets
$ grind definitions > grind.d.rhaiand .vscode/settings.json:
{
"files.associations": {
"*.grind": "kdl"
},
"[kdl]": { "editor.snippetSuggestions": "top" },
"[rhai]": { "editor.snippetSuggestions": "top" }
}The second file is the projection's nodes. It is written by hand rather than generated, and the binary carries a copy, so it needs no checkout of this repository.
files.associations is the load-bearing line. Without it .grind is plain text: no
highlighting, no snippets, nothing. With it, VS Code treats the file as KDL, which is what it
is.
Regenerate the snippets when you upgrade grind. The first file is the engine's own answer to
what can a script say, so a new version of the binary may have new functions in it. There is
no harm in running the command in a Makefile.
Rhai has a mechanism for exactly this problem. grind definitions writes a Rhai definition
file — the language server's own format — holding every function grind build registers,
with its parameter names, its types and its documentation:
/// Append a row, and answer the index of the row it landed on (counted from 0).
///
/// `s.at(s.push(…), 2)` is an address in the row just written.
fn push(sheet: Sheet, row: Row) -> int;That is the right answer, and it needs a language server to read it. Measured against the
published extension, there is not one available. What was found, in
rhaiscript.vscode-rhai 0.6.9 as installed:
- The extension ships a TextMate grammar and no server. Its
package.jsoncontributes arhai.useLanguageServersetting, and itsextension.jsstarts aLanguageClientwhose command is the stringrhai-lsp— a binary expected onPATH, not bundled. - It offers to install that binary with
cargo install rhai-lsp. There is no crate published under that name. - And the client would not start in any case:
activate()reads the setting out of the configuration sectionnotedownrather thanrhai, souseLanguageServeris alwaysundefined, which is falsy, and the branch that starts the client is never taken.
So doc/generator-spec.md §8's sentence about that extension bundling a language server is
wrong, and has been corrected to point here.
Snippets need no server. VS Code reads *.code-snippets out of the workspace itself; all it
needs from an extension is the language id — rhai, kdl — which the two syntax extensions
in §5 register and which is what you wanted them for anyway. A snippet then carries a name, a
body with tab stops, and a description that shows in the completion item's documentation pane.
That is completion and inline documentation for the whole vocabulary, with one moving part fewer
than the correct answer has.
Both files come from the same place, which is the property worth having:
grind definitions and grind definitions --snippets are each the engine's answer, so
neither can describe a function that is not there. And a function cannot be registered without
its documentation — build/src/hint.rs takes the doc comment as an argument and there is no
shorter way to register one — so an undocumented function cannot be added by forgetting.
cli/tests/editor.rs fails the build when either shipped file goes stale.
Keep grind.d.rhai anyway. It costs one command, an editor that reads it may appear, and it is
the most readable single-file reference to the vocabulary there is — examples/grind.d.rhai is
a copy kept in this repository for exactly that reading.
Both were read as installed, rather than taken from a marketplace description.
Version 2.1.3. A KDL grammar, language-configuration.json for brackets and comments, and a
bundled language server that reports syntax diagnostics (kdl.enable, kdl.command,
kdl.argv, kdl.loglevel).
It supports KDL v2 only, which is the version the projection is written in — #true,
#false and #null are v2 spellings, and kdl-rs 6.x is the parser underneath grind. If
something highlights a .grind as though every # began a comment, an extension for KDL v1
has claimed the file.
What its diagnostics cover is KDL: an unclosed brace, a bad escape, a malformed node. They
know nothing about sheet, cell or a range, so a format … percent that should be
percentage looks fine to the editor and is caught by grind lint.
Version 0.6.9. A Rhai grammar, and the language server situation in §4. Highlighting, bracket matching and comment toggling all work; nothing else in the extension does.
There is a second extension, mikai233.rhai-analyzer, which claims completion, hover and
diagnostics from an analyzer of its own. It was not tested here and is not recommended in
.vscode/extensions.json; if you try it, note that its formatter configuration is a
rhai.toml, which is a different file from the .d.rhai definitions above.
The check an editor cannot do, bound to a key. .vscode/tasks.json:
{
"version": "2.0.0",
"tasks": [
{
"label": "grind lint",
"type": "shell",
"command": "grind lint ${file}",
"problemMatcher": {
"owner": "grind",
"fileLocation": ["autoDetected", "${workspaceFolder}"],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+): (.+?): (error|warning): (.*)$",
"file": 1,
"line": 2,
"column": 3,
"code": 4,
"severity": 5,
"message": 6
}
},
"group": "test"
},
{
"label": "grind build",
"type": "shell",
"command": "grind build ${file} -o ${fileDirname}/${fileBasenameNoExtension}.fods",
"group": "build"
}
]
}For a .grind, every finding whose address the file spells comes out in the compiler's shape
— quote.grind:58:34: Quote.B11: warning: … [misspelt] — with the line and column read from
the file as written, comments and alignment included, through the projection's span map. The
matcher above takes the address as the problem's code, so it is still there to type back at
grind. Two kinds of finding have no line and the matcher skips them: one about a document in
another form (a .fods has no line a person wrote), and one whose address the file never
spelled on its own, such as a cell covered only by a style range. Both still print, by
address. --format json carries the same thing as a location object, with an end as well as
a start, for anything that wants to underline a range.
Nothing above is VS Code's except the file names.
.grind |
.rhai |
|
|---|---|---|
| Zed, Helix, Neovim | a KDL tree-sitter grammar exists and covers the file once the extension is mapped | a Rhai tree-sitter grammar exists; grind definitions output feeds anything that speaks the Rhai language server protocol |
| Anything with LSP | grind lsp — §7.1 |
grind lsp — §7.1 |
| Anything at all | grind lint, grind sheet project, grind info |
grind build |
The one setting worth carrying across is the association: .grind is KDL, and no editor guesses
that from the extension.
grind lsp speaks the Language Server Protocol over stdin and stdout (doc/init.md I5), and is
what lifts every ○ in §1's table for an editor that can start one:
.grind |
.rhai |
|
|---|---|---|
| Diagnostics | a read error where it says, every grind lint finding on its cell's line — spelling included |
the syntax error, from the compiler grind build uses — compiled, never run |
| Completion | the nodes; inside a formula, every function this build has and the document's names | after s., the methods and properties; anywhere else, the free functions — each with its documentation rendered |
| Hover | a cell's formula, through its names, and what it shows; a name's definition; a block's addresses | every registration of the name under the pointer, signature and documentation |
| Inline values | what every formula computes, after it — shown, never written; grind sheet recalc still writes them |
— |
| Go to definition | a name's name node, a sheet's sheet node |
— |
It does not analyse Rhai: it does not know what s holds, so after a dot it offers every
method of every host type, and it says nothing about the script's own variables or functions
(doc/dsl.md §9). And it never runs a script — opening a .rhai that would loop forever is as
quick as opening any other.
VS Code starts a server through a client extension, and grind's is in this repository,
editors/vscode/ (doc/init.md I6). It is not published anywhere yet; build and install it:
$ editors/vscode/package.sh # writes target/grind-0.0.1.vsix
$ code --install-extension target/grind-0.0.1.vsixCI builds the same file on every push (artifacts.yml's vscode job, artifact grind-vscode).
It starts grind lsp from grind.path (default grind), contributes a grind language with its
own highlighting — so a .grind is recognised even in an untrusted folder, where the
files.associations line of §3 is ignored — and declares rhai too, so a script is served with
or without the Rhai extension. grind.path is honoured from a folder's settings only once the
folder is trusted: in Restricted Mode a folder cannot choose the program VS Code runs.
editors/vscode/test/run.sh is its test: a real VS Code, headless, with a fresh profile, no other
extension and the folder untrusted, asked through its own completion, hover, inlay-hint and
diagnostic commands.
Helix (languages.toml, global or a project's .helix/):
[language-server.grind]
command = "grind"
args = ["lsp"]
[[language]]
name = "grind"
scope = "source.grind"
file-types = ["grind"]
grammar = "kdl"
comment-token = "//"
indent = { tab-width = 4, unit = " " }
language-servers = ["grind"]
[[language]]
name = "rhai"
scope = "source.rhai"
file-types = ["rhai"]
comment-token = "//"
indent = { tab-width = 4, unit = " " }
language-servers = ["grind"]Neovim (0.11 or later, init.lua):
vim.filetype.add({ extension = { grind = "grind", rhai = "rhai" } })
vim.treesitter.language.register("kdl", "grind")
vim.lsp.config("grind", { cmd = { "grind", "lsp" }, filetypes = { "grind", "rhai" } })
vim.lsp.enable("grind")None of the three editors above was installed where this was written, so these two blocks are
the editors' documented shapes and have not been run; what has been is the server itself,
over real stdio, by cli/tests/lsp.rs. Inline values are an inlay hint in the protocol, which
Neovim shows after vim.lsp.inlay_hint.enable(true).
A .grind is plain text, and no task runs |
the folder is in Restricted Mode — VS Code's state for a folder it has not been told to trust — and there it ignores the workspace's files.associations, so the snippets, scoped to kdl, never apply; it also refuses to run tasks. Trust the folder (Restricted Mode in the status bar, or Workspaces: Manage Workspace Trust), or put the association in your user settings, which apply either way. Measured: untrusted, book.grind opened as plaintext; trusted, as kdl with its cell snippets. A .rhai is unaffected, because the Rhai extension claims the file type itself |
| The description is missing from a completion | it is the item's detail line, shown only when the details are open: press Ctrl+Space again while the list is up |
Nothing is offered after s. |
expected: a snippet needs a letter (s.pu), and nothing knows what s is without a language server — grind lsp offers the methods after a dot (§7.1); in VS Code, install its client extension, editors/vscode |
No highlighting in a .grind |
the files.associations entry is missing, or the window needs reloading after adding it. The language shown in the status bar should read KDL |
| No completions | snippets are suppressed when editor.snippetSuggestions is "none"; the settings above set it to "top" for these two languages. Check that the .code-snippets file is in the workspace folder's .vscode/, not a parent's |
| Completions for functions that do not exist | the snippet file is from an older grind — regenerate it (§3) |
#true highlighted as a comment |
a KDL v1 extension has claimed the file; the projection is v2 (§5) |
| The Rhai extension offers to install a language server | decline. §4 is why |
| A script fails and the editor said nothing | it would: nothing published type-checks Rhai. grind build is the check, and its errors carry a line and a column |