LSP-based diagnostics, completion, hover, signature help, and semantic highlighting for ACM (Advanced Cluster Management) Helm policy templates, plus treesitter injections for accurate Go-template tokenization inside YAML block scalars.
Sibling project to the VSCode extension at autoshift-plugin — both
read the same JSON catalogs at the data level. Diverged here so the
Neovim plugin can ship and version independently.
| Feature | LSP method | Implementation |
|---|---|---|
| Diagnostics (10 rules) | textDocument/publishDiagnostics |
lsp-server/internal/rules/ |
| Completion | textDocument/completion |
lsp-server/internal/providers/completion.go |
| Hover | textDocument/hover |
lsp-server/internal/providers/hover.go |
| Signature help | textDocument/signatureHelp |
lsp-server/internal/providers/signaturehelp.go |
| Semantic tokens | textDocument/semanticTokens/full |
lsp-server/internal/providers/semantictokens.go |
| Token-level highlighting inside YAML block scalars | treesitter injection | plugin/queries/yaml/injections.scm |
| ACM-identifier highlighting | treesitter overlay | plugin/queries/{helm,gotmpl}/highlights.scm |
| Rule | Default | Severity | What it catches |
|---|---|---|---|
policy-name-length |
on | warning | Policy / PlacementBinding / etc. with metadata.name longer than the configured maximum (default 40 — typical enterprise CI gate). |
policy-namespace-length |
on | warning | Same kinds with metadata.namespace longer than the configured maximum (default 20). Skipped silently when no namespace is declared. |
policy-name-pattern |
off | warning | Names that don't match a configurable regex. |
policy-name-template |
on | warning | Names containing template expressions; mode = strict flags any template, resolve renders against values.yaml and checks the result, both runs both checks. |
hub-forbidden-functions |
on | error | Use of functions that aren't valid in hub-template context (trimPrefix, trimSuffix, …). |
lookup-default-dict |
on | warning | lookup calls without a | default dict "" fallback that would crash at policy-eval time when the resource is missing. |
unclosed-delimiters |
on | error | Unbalanced {{ / }}, plus orphan markers across four layers: helm-level, direct hub {{hub/hub}}, hub-escape {{ "{{hub" }} / {{ "hub}}" }}, and managed-escape {{ "{{" }} / {{ "}}" }}. |
unclosed-parens |
on | warning | Per-expression paren balance inside {{ … }}. String literals and {{/* … */}} comments are skipped so non-structural parens don't count. Complements template-syntax with earlier-in-edit feedback. |
unknown-function |
off | warning | Function-call identifiers in any layer that aren't in the loaded catalog (helm + hub + managed + sprig + Go-builtins). Default off because the shipped sprig coverage is intentionally a subset; opt in once your chart's sprig usage is in the catalog or extend with rules.unknown-function.allowedFunctions. |
template-syntax |
on | warning | Per-object-templates-raw: block-scalar Go-template parse via text/template/parse (the parser helm itself uses). Catches malformed actions, control-flow nesting errors, bad pipelines, mismatched parens. All catalog functions plus hub are registered as no-op stubs so legitimate ACM calls don't appear undefined. Recognizes {{/* … */}} comments (single- and multi-line) as opaque. Variables defined at chart-top ({{- $var := … -}} outside the block scalar) get phantom declarations prepended before parsing so legitimate references inside the block don't surface as "undefined variable". Two opt-in modes: layered = true runs a render-chain across helm/hub/managed layers (catches errors visible only after helm renders); typedStubs = true builds catalog-typed stub signatures via reflect.MakeFunc so wrong arity or incompatible literal types surface during render. |
The semantic-token classifier handles the three levels of go-template nesting that ACM templates produce:
- Helm-level expressions —
{{ ... }}at the chart layer, including string literals that contain rendered helm expressions ("{{ $polNs }}"is one tString covering the whole literal; the inner helm expression's bytes get their own classification overlaid). - Direct hub spans —
{{hub fn args hub}}body, withhubtagged as an ACM-side keyword (@lsp.typemod.keyword.defaultLibrary.<lang>) separate from go-template control keywords likeif/range. - Escape forms —
{{ "{{hub" }} … {{ "hub}}" }}(hub-escape) and{{ "{{" }} … {{ "}}" }}(managed-escape). The runtime delimiters inside the string literals ({{/}}/{{-/-}}) are tagged as ACM-side keywords. Helm expressions embedded inside the body of either escape form (e.g."{{ $polNs }}"inside a hub-escape) are correctly recognized as helm-level — their bytes don't fight with ACM-side classification.
The repo ships a script that builds the LSP and symlinks the Lua plugin into Neovim's native pack path:
# from the repo root
scripts/install.shThen in ~/.config/nvim/init.lua:
require("acm-ls").setup({
cmd = { "/absolute/path/to/nvim-acm/lsp-server/acm-ls" },
})For richer highlighting inside YAML block scalars, install the gotmpl treesitter parser:
:TSInstall yaml gotmplOpen a .yaml or helm-detected policy file. :LspInfo should list
acm-ls attached. :Inspect (Neovim 0.10+) on a hub function
shows the semantic token classification.
nvim-acm/ # standard nvim plugin layout
├── lsp-server/ # Go binary, single-file deploy
│ ├── main.go # entry — runs server over stdio
│ ├── catalogs/ # JSON: ACM funcs + helm + go-builtins
│ ├── cmd/smoketest/ # JSON-RPC client that exercises the LSP
│ └── internal/
│ ├── catalog/ # JSON loader, types
│ ├── parsedoc/ # YAML kind/name + LSP range conversion
│ ├── context/ # layer detection (helm/hub/managed),
│ │ # hub-span finder, ACM-context check
│ ├── values/ # values.yaml parser, overlays merge,
│ │ # .Values.* path parser, mini renderer
│ ├── rules/ # 10 diagnostic rule implementations
│ ├── providers/ # completion, hover, signature, sem-tokens
│ └── server/ # glsp wiring + stateful per-document handlers
├── lua/acm-ls/
│ ├── init.lua # setup() registers vim.lsp.start on FileType
│ └── treesitter.lua # parser-availability check
├── plugin/acm-ls.lua # auto-load guard
├── queries/ # treesitter overlays
│ ├── yaml/injections.scm # inject gotmpl into block_scalar content
│ ├── gotmpl/highlights.scm # ACM-distinct funcs/values
│ └── helm/highlights.scm # same overlay for helm filetype
└── scripts/install.sh # build + symlink
- Client opens a file → Neovim's LSP client sends
textDocument/didOpen. The Lua plugininit.luawires this up viavim.lsp.startonFileType yaml,helmautocmd. - Server receives didOpen →
internal/server/server.gostores the text and callspublishDiagnostics, which runs every rule frominternal/rules/against parsed YAML docs. - Cursor request (completion / hover / signature help) →
internal/providers/<x>.gois called. It reads the resolved catalog (loaded once at startup), runs the layer detector to know if the cursor is in helm / hub / managed context, and returns layer-appropriate items. - Semantic tokens → on
textDocument/semanticTokens/fullthe server walks every{{ ... }}expression and hub-template span, classifying each token (operator, keyword, function, string, …) into LSP delta-encoded data. - Treesitter highlight overlays run independently of the LSP —
they're query-only files in
plugin/queries/that nvim-treesitter merges with the underlying yaml/helm/gotmpl grammars.
<binary-dir>/catalogs/(or$ACM_CATALOGS_DIRif set):acm-<version>.json— hub funcs, managed funcs, sprig subset, exported valueshelm.json— helm-layer functionsgo-builtins.json— Go template builtins (index, len, eq, …)
- Per-document at runtime: chart-local
values.yaml(cached), plus any overlay files configured viaacm.values.overlayFiles.
The catalog files in lsp-server/catalogs/ are the source of truth
for this repo. The sibling VSCode extension (autoshift-plugin) keeps
its own copy and is updated separately.
setup() in init.lua accepts:
| Key | Default | Notes |
|---|---|---|
cmd |
{ "acm-ls" } |
Path or argv to launch the binary. |
filetypes |
{ "yaml", "helm" } |
Filetypes to attach the LSP to. |
root_markers |
{ "Chart.yaml", ".git", "policies" } |
Walked upward; first match wins. |
semantic_tokens |
true |
Calls vim.lsp.semantic_tokens.start on attach (Neovim 0.10+). |
apply_default_highlights |
true |
Register the per-language @lsp.type.*.<lang> and @lsp.typemod.*.<lang> link table. Set false to leave highlight setup entirely to your colorscheme. |
highlights |
{} |
Per-group overrides — keys are LSP semantic-token groups (@lsp.typemod.keyword.defaultLibrary.helm, @lsp.type.variable.yaml, …), values are any spec accepted by nvim_set_hl ({ fg = "…" }, { link = "…" }, …). Replaces the default link entirely; re-applied on ColorScheme and LspAttach so theme changes don't wipe it. |
warn_missing_parsers |
true |
Notify if gotmpl/yaml treesitter parsers aren't installed. |
settings |
see init.lua |
Forwarded to the server via initializationOptions. |
To distinguish ACM-side markers from go-template keywords in your colorscheme, target the semantic-token groups directly:
require("acm-ls").setup{
highlights = {
-- ACM `hub` keyword and the inner `{{`/`}}` of escape patterns
["@lsp.typemod.keyword.defaultLibrary.helm"] = { fg = "#558b2f", bold = true },
["@lsp.typemod.keyword.defaultLibrary.yaml"] = { fg = "#558b2f", bold = true },
-- ACM-distinct hub/managed functions (fromSecret, etc.)
["@lsp.typemod.function.defaultLibrary.helm"] = { fg = "#7aa2f7", bold = true },
["@lsp.typemod.function.defaultLibrary.yaml"] = { fg = "#7aa2f7", bold = true },
-- ACM exported context values (.ManagedClusterName, etc.)
["@lsp.typemod.property.readonly.helm"] = { fg = "#f7768e" },
["@lsp.typemod.property.readonly.yaml"] = { fg = "#f7768e" },
-- Plain template variables ($var)
["@lsp.type.variable.helm"] = { fg = "#ff9e64" },
["@lsp.type.variable.yaml"] = { fg = "#ff9e64" },
},
}@lsp.typemod.<type>.<modifier>.<lang> is Neovim's combined
type+modifier group — the most specific in the per-token chain. Plain
@lsp.type.<type>.<lang> covers the no-modifier case.
The settings.acm.* tree mirrors the VSCode extension's
package.json configuration schema, including all rule knobs:
settings = {
acm = {
enabled = true,
acm = { version = "2.15" },
rules = {
["policy-name-length"] = { enabled = true, severity = "warning", maxLength = 40 },
["policy-namespace-length"] = { enabled = true, severity = "warning", maxLength = 20 },
["policy-name-pattern"] = { enabled = false, pattern = "^policy-" },
["policy-name-template"] = { enabled = true, mode = "strict" }, -- or "resolve"/"both"
["hub-forbidden-functions"] = { enabled = true, severity = "error" },
["lookup-default-dict"] = { enabled = true, severity = "warning" },
["unclosed-delimiters"] = { enabled = true, severity = "error" },
["unclosed-parens"] = { enabled = true, severity = "warning" },
["unknown-function"] = { enabled = false, severity = "warning", allowedFunctions = {} },
["template-syntax"] = {
enabled = true,
severity = "warning",
layered = false, -- Phase A: helm → hub → managed parse-chain
typedStubs = false, -- Phase B.2: catalog-signature arity/type checking
},
},
values = {
overlayFiles = {}, -- workspace-relative paths layered on top of chart values
},
},
}Three top-level kinds you can add to:
Append to the appropriate array in lsp-server/catalogs/acm-<version>.json.
managedFunctions, sprigFunctions use the same shape. For Helm
chart-layer functions edit lsp-server/catalogs/helm.json. For Go
template builtins edit lsp-server/catalogs/go-builtins.json.
Append to hubExportedValues (cluster-scope context) or
managedExportedValues (object-scope, only inside objectDefinition):
"hubExportedValues": [
...,
{
"name": ".SomeNewField",
"type": "string",
"description": "What this resolves to at template-evaluation time.",
"source": "RHACM 2.15 Governance §1.2.1 Table 1.4"
}
]The leading . is part of the name. The completion provider strips
it for filtering and insert text so users type .S and the item
matches.
# from repo root
scripts/install.sh --build-only
# inside Neovim
:AcmRestartRules live under lsp-server/internal/rules/. Create
myrule.go modeled after the existing rules:
package rules
import (
"github.com/acm-ls/lsp-server/internal/parsedoc"
protocol "github.com/tliron/glsp/protocol_3_16"
)
type myRule struct{}
func (myRule) ID() string { return "my-rule" }
func (myRule) Run(ctx Context) []protocol.Diagnostic {
if !Get(ctx.Settings, "rules.my-rule.enabled", true) {
return nil
}
severity := Severity(Get(ctx.Settings, "rules.my-rule.severity", string(SeverityWarning)))
out := []protocol.Diagnostic{}
for _, d := range ctx.Docs {
// ... your logic ...
sev := severity.ToLSP()
code := protocol.IntegerOrString{Value: "my-rule"}
source := "acm"
out = append(out, protocol.Diagnostic{
Range: parsedoc.RangeFromNode(d.NameNode),
Severity: &sev,
Code: &code,
Source: &source,
Message: "your message here",
})
}
return out
}
var MyRule Rule = myRule{}Then register it in internal/server/server.go's rule list:
rules: []rules.Rule{
rules.PolicyNameLength,
// ...
rules.MyRule,
},Get, GetInt, GetStringSlice in rules/types.go provide
dotted-path access to the user's settings tree forwarded via
initializationOptions. Any setting you read (rules.my-rule.enabled,
rules.my-rule.severity, etc.) becomes user-configurable in their
init.lua automatically — no separate registration step.
For rules that need to scan hub-template content, the helpers in
internal/context/hubspans.go give you span ranges. For rules that
need values resolution (like the resolve mode of
policy-name-template), inject values.Cache via a constructor —
see NewPolicyNameTemplate for the pattern.
cd lsp-server
go test ./...Currently exercised:
context/: layer detector + hub-span findervalues/: path parser, template renderer, composeproviders/: semantic-token vocabulary + emission, including nested-helm-inside-hub-body and escape-form inner-delimiter casesrules/:unclosed-delimitersandunknown-functioncover their positive and negative cases (balanced docs, partial typing, escape-form orphans, allowedFunctions extension, dedupe)
cd lsp-server
go build -o acm-ls .
go build -o smoketest ./cmd/smoketest/
./smoketest ./acm-lsThe smoketest spawns the binary, sends initialize +
textDocument/didOpen + cursor requests against canned fixtures, and
reports pass/fail per LSP capability. Useful for verifying changes
without launching Neovim.
After running scripts/install-nvim.sh, open a policy YAML in nvim:
:LspInfo " confirm acm-ls is attached
:lua vim.lsp.buf.hover() " on a hub function
:lua vim.lsp.buf.completion() " or trigger via your completion plugin
:Inspect " (Neovim 0.10+) shows semantic-token info
:LspLog " stderr from the server (parse errors, etc.)The template-syntax rule parses each object-templates-raw: block
scalar in isolation — it does not see the surrounding chart
structure (other YAML keys, sibling templates, the chart's
Chart.yaml, etc.). This keeps each diagnostic focused on the block
the user is editing, but means the parser doesn't have the chart-top
context out of the box. Two specific accommodations:
- Chart-top variable declarations (
{{- $policyNamespace := .Values.x -}}at the document top) — phantom declarations are prepended to each block-scalar body before parsing so$varreferences inside the block don't surface as "undefined variable". Variables declared within the block itself are detected and not given phantoms (avoids the parser's no-redeclaration rule). - Chart-derived
.Values.*data — thelayeredmode reads the chart'svalues.yamlplus anyacm.values.overlayFilesand pre-populates intermediate paths via Phase B.1 access-path collection so chained navigation (.Values.foo.bar.baz) doesn't nil-pointer Execute.
Default behavior is helm-layer parse only. layered = true
unlocks the three-stage render-chain (Phase A.1–A.4) which catches
syntax errors at all three layers (helm → hub → managed). Defaults
off because real-world templates routinely access values that
aren't always defined, and the render chain currently silently
skips stage 2/3 when stage 1 Execute fails — Phase B work is
making that more robust.
{{/* … */}} go-template comments (including multi-line bodies and
bodies containing {{/}} literals) are recognized by every
expression-interior scanner: the unclosed-delimiters state machine,
unknown-function's expression walker, semantic-token span detection,
and the hub-marker false-match filter. Trim-marker comments
({{- /* … */ -}}) work too.
- Variable type compatibility —
$x := <dict>then passed to something that expects a string is a Phase B.3/B.4 problem (cross-stage type continuity). The variable-inference machinery isn't there yet. - Function arity for catalog-incomplete entries —
typedStubs = trueonly catches arity/type mismatches when the catalog declares fullparamsandreturns.type. Functions without complete signatures fall back to permissive untyped stubs. - Cross-document references (e.g.,
PlacementBinding.placementRef.namepointing at a non-existentPlacement) — needs a workspace-wide index, tracked inTODOS.md. - Real helm rendering with actual cluster lookups — the
lookupfamily of functions is stubbed; the LSP is a static analyzer, not a renderer.
YAML's grammar consumes object-templates-raw: | block-scalar
content as a single opaque token. The TextMate grammar in the VSCode
extension couldn't reach inside that span, which broke bracket
matching for escape patterns like
{{ "{{hub-" }} ... {{ "hub}}" }} (VSCode's pair counter saw 3 {{
and 3 }} in the line and flagged the outer ones as unmatched).
Treesitter's injection query in plugin/queries/yaml/injections.scm
tells the parser to re-tokenize the inner block-scalar content using
the gotmpl grammar. Bracket pairing then follows the AST: the inner
{{ of "{{hub-" is a string-content character (not a bracket),
and the outer {{/}} pair correctly. No client-side workarounds
needed.
The catalog JSON files in lsp-server/catalogs/ were seeded from the
Red Hat Advanced Cluster Management for Kubernetes 2.15 Governance
documentation, specifically §1.2 Template Processing. Each entry
carries a source field referring back to the section that defines
it. When ACM 2.16 ships, drop a new acm-2.16.json file alongside —
the loader auto-discovers any acm-*.json and lets users select
their version via acm.acm.version.
A long-term goal: a CI step that regenerates these catalogs from a
fresh PDF + a checkout of stolostron/go-template-utils. For now,
they're hand-curated.