No description
  • C 82.3%
  • JavaScript 12.3%
  • Tree-sitter Query 4.4%
  • Scheme 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Maciej Lebiest 9d2faccf72 fixes
2026-08-15 17:10:49 +02:00
examples/nvim fixes 2026-08-15 17:10:49 +02:00
queries fixes 2026-08-15 17:10:49 +02:00
src first commit 2026-08-15 11:50:16 +02:00
test/corpus first commit 2026-08-15 11:50:16 +02:00
.gitignore fixes 2026-08-15 17:10:49 +02:00
AGENTS.md first commit 2026-08-15 11:50:16 +02:00
grammar.js first commit 2026-08-15 11:50:16 +02:00
LICENSE first commit 2026-08-15 11:50:16 +02:00
package.json first commit 2026-08-15 11:50:16 +02:00
README.md fixes 2026-08-15 17:10:49 +02:00
tree-sitter.json first commit 2026-08-15 11:50:16 +02:00

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-children is 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 coherent stream spanning 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 jinja before 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 separate ERROR-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.installed is 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-jinja 0.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:

  1. the SLS tree-sitter tree for structure,
  2. YAML parsing for data shapes,
  3. Jinja analysis for templating,
  4. the installed Salt environment for introspection,
  5. 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.