- C 82.3%
- JavaScript 12.3%
- Tree-sitter Query 4.4%
- Scheme 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| examples/nvim | ||
| queries | ||
| src | ||
| test/corpus | ||
| .gitignore | ||
| AGENTS.md | ||
| grammar.js | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tree-sitter.json | ||
tree-sitter-sls
WARNING: Experimental — AI-slopped project.
This project is an experimental result produced by AI sloppy development. Expect rough edges, incomplete features, unverified edge cases, and design decisions that may not hold up in practice. Do not treat it as production-quality or authoritative. Verify everything against real Salt/Jinja/YAML behavior before relying on it.
Tree-sitter grammar for SaltStack SLS files, providing high-quality parsing and editor integration, with a focus on Neovim.
What is SLS?
SLS (Salt State) files describe configuration states for Salt. A template renderer (usually Jinja) processes each file, the result is parsed as YAML, and Salt compiles the result into state structures:
SLS source Jinja rendering YAML parsing Salt state compilation
Jinja + YAML text -> plain YAML text -> structured YAML -> Salt state graph
Because rendering happens before YAML parsing, an SLS file is a mixed-language document:
{% if grains['os'] == 'Debian' %}
nginx:
pkg.installed: []
{% endif %}
The top-level layout is YAML, but Jinja constructs can appear anywhere — at block boundaries, inside flow scalars, or wrapped around whole YAML structures.
Why mixed-language parsing?
Reinterpreting SLS as ordinary YAML fails immediately: the YAML grammar would choke on {% ... %} tags. Reinterpreting it as ordinary Jinja hides the YAML structure (indentation, keys, and lists) that editors need for useful syntax highlighting, folding, and navigation.
This grammar instead recognizes thin structure: where YAML content lives, and where Jinja statements, expressions, comments, and raw blocks live. It then delegates the actual parsing of each region to dedicated Jinja and YAML grammar parsers via tree-sitter injections.
Architecture
tree-sitter-sls
│
┌─────────┴─────────┐
│ │
Jinja regions YAML regions
│ │
Jinja parser YAML parser
│ │
└─────────┬─────────┘
│
Neovim
The grammar uses an external scanner (src/scanner.c) to split source text into Jinja constructs and contiguous YAML regions. The SLS grammar itself stays intentionally small:
source_file
├── jinja_statement {% ... %}
├── jinja_expression {{ ... }}
├── jinja_comment {# ... #}
├── jinja_raw_block {% raw %}...{% endraw %}
└── yaml_region everything between Jinja constructs
Design notes:
- Adopts Design B from the planning docs: a small dedicated SLS root instead of making Jinja the root. This keeps the grammar independent of any particular Jinja grammar's text-node representation.
- YAML regions are contiguous and passed to the YAML parser through injection queries.
- The grammar does not attempt to validate Salt semantics (see Future Salt LSP).
Supported Jinja constructs
- Statements:
{% ... %} - Expressions:
{{ ... }} - Comments:
{# ... #} - Raw blocks:
{% raw %}...{% endraw %} - Whitespace-control delimiters:
{%- ... -%},{{- ... -}},{#- ... #} - Multiline statements/expressions
+variants:{%+ ... %},{{+ ... }}
Not every { or } is treated as a Jinja delimiter — plain YAML text is left intact.
Repository layout
tree-sitter-sls/
├── grammar.js grammar definition
├── src/scanner.c external scanner (Jinja/YAML boundary detection)
├── package.json
├── tree-sitter.json
├── queries/
│ ├── highlights.scm
│ └── injections.scm
├── test/corpus/ corpus tests
└── examples/nvim/ minimal Neovim integration
Installation
Tree-sitter CLI basics
npm install # installs tree-sitter-cli
npm run build # generates src/parser.c
npm run build-wasm # optional: wasm build
Build the parser for your editor
For Neovim you need a shared library:
tree-sitter build -o ~/.local/share/nvim/site/parser/sls.so
yaml and jinja parsers must also be installed for injections to activate.
Neovim integration
An example integration lives in examples/nvim/:
-- register the sls parser (needs the .so built above)
vim.treesitter.language.add('sls', { path = vim.fn.expand('~/.local/share/nvim/site/parser/sls.so') })
-- detect .sls files
vim.filetype.add({
extension = { sls = 'salt_sls' },
})
-- enable highlighting and injections
vim.treesitter.start(buf, 'sls')
examples/nvim/plugin/tree-sitter-sls.lua wires these up via FileType/BufEnter autocmds. The example module also exposes sls.inspect(buf) to dump the resulting SLS tree plus injected child trees.
Tested with Neovim v0.12.4 (vim.treesitter.start, vim.treesitter.language.add). The parser .so can also be placed anywhere on the runtimepath (parser/sls.so).
How injections work
queries/injections.scm passes regions to dedicated parsers:
((jinja_statement) @injection.content
(#set! injection.language "jinja")
(#set! injection.include-children))
((jinja_expression) @injection.content
(#set! injection.language "jinja")
(#set! injection.include-children))
((jinja_comment) @injection.content
(#set! injection.language "jinja")
(#set! injection.include-children))
((jinja_raw_block) @injection.content
(#set! injection.language "jinja")
(#set! injection.include-children))
((yaml_region) @injection.content
(#set! injection.language "yaml")
(#set! injection.combined))
Notes:
injection.include-childrenis required. Neovim's injection machinery masks out named children of the injected node unless this directive is set, which would otherwise leave only the{%/%}delimiters injected instead of the full tag contents.- Jinja nodes are injected individually. The injected Jinja tree therefore contains each tag as an independent source; see Known limitations.
- YAML regions are injected with
injection.combined, so the YAML parser sees one coherentstreamspanning all YAML regions even when inline Jinja splits a single logical document (or a scalar) into several fragments. - Injection requests that reference an unavailable parser (for example
jinjabefore you install a Jinja parser) are skipped, leaving the region unhighlighted rather than erroring.
Known limitations
- Block-level Jinja reconstruction — because each Jinja tag is injected individually, the injected Jinja parser cannot rebuild the surrounding template structure (e.g.
{% if %}…{% endif %}parse only as separateERROR-wrapped tags). Tag contents themselves parse fine. Syntactic recovery remains useful for editing/highlighting. - Inherently invalid YAML fragments — YAML regions containing Jinja already render to invalid YAML even when the template is valid (e.g. a scalar split by
{{ ... }}). This is not a bug in this grammar; it reflects the SLS render pipeline. The combined YAML injection mitigates the symptom. - No semantic validation — the grammar never checks that
pkg.installedis a real Salt state, requisites point at existing states, or pillar variables are defined. All of that is LSP territory. - Tested parsers — the Jinja parser used during validation was
tree-sitter-jinja0.3.3 (npm) as well as the grammar installed by tree-sitter-manager (cathaysia/tree-sitter-jinja). Other Jinja grammars should work but are untested.
How to run tests
tree-sitter test
The corpus under test/corpus/ covers plain YAML, all Jinja constructs, whitespace control, raw blocks, multiline content, inline Jinja, and distinctly incomplete/malformed editing states (e.g. an unclosed {{ or a trailing foo:).
Future Salt LSP integration
This grammar intentionally stops at syntax. A future Salt language server should use:
- the SLS tree-sitter tree for structure,
- YAML parsing for data shapes,
- Jinja analysis for templating,
- the installed Salt environment for introspection,
- optionally Salt's own renderer/compiler for deeper checks.
The node tree already carries useful anchors (jinja_statement, jinja_expression, jinja_comment, yaml_region) with precise source ranges for an LSP to address.
License
MIT — see LICENSE.