feat(language): add a Helm Template language for chart templates (#3261)

Files in a Helm chart's templates folder (yaml, yml, tpl, txt under a Chart.yaml / Chart.yml root) and *.gotmpl files are detected as Helm Template instead of YAML or plain text. They contain Go text/template + Sprig actions that are not valid YAML until rendered, so the YAML language server, formatter and tree-sitter syntax check no longer attach to them and flood the editor with errors.

A CodeMirror stream mode highlights YAML outside {{ … }} and keywords, variables, dot paths, functions and comments (including {{- /* … */ -}}) inside. .tpl outside a chart keeps its current type, since Smarty, Terraform and other engines use it too.

Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com>
This commit is contained in:
Deng Liu
2026-11-14 10:46:56 +08:00
committed by GitHub
co-authored by dengliu
parent 5e5d804b89
commit 4d88d44616
8 changed files with 401 additions and 2 deletions
+26
View File
@@ -40,6 +40,7 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -548,6 +549,7 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -841,6 +843,30 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
+16
View File
@@ -0,0 +1,16 @@
id = "helm-template"
name = "Helm Template"
# `.tpl` is Smarty, Terraform and other template engines too: only charts claim it (below).
extensions = ["gotmpl"]
line-comment = "#"
block-comment = ["{{/*", "*/}}"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "Helm Template"
# Helm charts: YAML, template and notes files in a `templates` folder.
# No YAML formatter or strict YAML language server: Go `{{ … }}` actions are
# not valid YAML until rendered by `helm template`.
[[framework-files]]
folder = "templates"
extensions = ["yaml", "yml", "tpl", "txt"]
root-markers = ["Chart.yaml", "Chart.yml"]
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { completedSyntaxTree } from "@/test/syntax";
import { classHighlighter, highlightTree } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "./languages";
async function tokens(source: string) {
const desc = findLanguageDescription("Helm Template", "templates/applications.yaml")!;
const support = await loadSupport(desc);
const state = EditorState.create({ doc: source, extensions: [support!] });
const result: { text: string; style: string }[] = [];
highlightTree(completedSyntaxTree(state), classHighlighter, (from, to, style) =>
result.push({ text: source.slice(from, to), style }),
);
return result;
}
describe("Helm Template mode", () => {
it("highlights YAML outside and Go template actions inside {{ … }}", async () => {
const src = [
"{{- range .Values.applications }}",
"{{- $config := $.Values.config -}}",
"apiVersion: argoproj.io/v1alpha1",
"metadata:",
' name: {{ printf "example.%s" .name | quote }}',
' image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"',
"{{/* block note */}}",
"{{- end }}",
].join("\n");
const out = await tokens(src);
expect(out).toContainEqual(expect.objectContaining({ text: "range", style: "tok-keyword" }));
expect(out).toContainEqual(expect.objectContaining({ text: "$config", style: "tok-variableName" }));
expect(out).toContainEqual(expect.objectContaining({ text: ".Values.applications", style: "tok-propertyName" }));
expect(out).toContainEqual(
expect.objectContaining({ text: ".Values.image.repository", style: "tok-propertyName" }),
);
expect(out).toContainEqual(expect.objectContaining({ text: "apiVersion", style: "tok-propertyName" }));
expect(out).toContainEqual(expect.objectContaining({ text: "{{/* block note */}}", style: "tok-comment" }));
expect(out).toContainEqual(expect.objectContaining({ text: "end", style: "tok-keyword" }));
});
it("reads comments with trim markers", async () => {
const out = await tokens("{{- /* trimmed note */ -}}\nkind: Deployment");
expect(out).toContainEqual(expect.objectContaining({ text: "{{- /* trimmed note */ -}}", style: "tok-comment" }));
expect(out).toContainEqual(expect.objectContaining({ text: "kind", style: "tok-propertyName" }));
});
it("keeps multi-line {{/* … */}} comments across lines", async () => {
const out = await tokens("{{/* line 1\nline 2 */}}\nreplicas: 3");
expect(out).toContainEqual(expect.objectContaining({ text: "{{/* line 1", style: "tok-comment" }));
expect(out).toContainEqual(expect.objectContaining({ text: "line 2 */}}", style: "tok-comment" }));
expect(out).toContainEqual(expect.objectContaining({ text: "replicas", style: "tok-propertyName" }));
});
});
+296
View File
@@ -0,0 +1,296 @@
// Helm Template editor mode: YAML outside `{{ … }}`, Go `text/template` + Sprig/Helm
// actions and `{{/* … */}}` comments inside. Each region runs on a sub-stream bounded
// at the next delimiter so `{{ … }}` never breaks YAML key/value tokenization.
import { LanguageSupport, StreamLanguage, StringStream, type StreamParser } from "@codemirror/language";
import { tags } from "@lezer/highlight";
type HelmState<Y> = {
yaml: Y;
inAction: boolean;
inComment: boolean;
inRawString: boolean;
quote: '"' | "'" | null;
indentUnit: number;
};
const OPEN = /\{\{-?/;
const CLOSE = /-?\}\}/;
// `{{/* … */}}`, or with trim markers `{{- /* … */ -}}` (Go requires the space there).
const COMMENT_OPEN = /^\s*\/\*/;
const COMMENT_CLOSE = /\*\/\s*-?\}\}/;
const KEYWORDS = new Set(["if", "else", "end", "range", "with", "define", "template", "block", "break", "continue"]);
const ATOMS = new Set(["true", "false", "nil"]);
const FUNCTIONS = new Set([
"and",
"b64dec",
"b64enc",
"call",
"coalesce",
"contains",
"default",
"dict",
"empty",
"eq",
"fail",
"first",
"fromJson",
"fromYaml",
"ge",
"get",
"gt",
"has",
"hasKey",
"hasPrefix",
"hasSuffix",
"html",
"include",
"indent",
"index",
"int",
"int64",
"join",
"js",
"keys",
"kindIs",
"kindOf",
"last",
"le",
"len",
"list",
"lookup",
"lower",
"lt",
"merge",
"mustMerge",
"ne",
"nindent",
"not",
"or",
"pluck",
"print",
"printf",
"println",
"quote",
"regexMatch",
"replace",
"required",
"semverCompare",
"set",
"sha256sum",
"slice",
"snakecase",
"squote",
"ternary",
"title",
"toJson",
"toPrettyJson",
"toString",
"toStrings",
"toToml",
"toYaml",
"tpl",
"trim",
"trimAll",
"trimPrefix",
"trimSuffix",
"trunc",
"typeIs",
"typeOf",
"unset",
"upper",
"urlquery",
"values",
]);
function tokenAction<Y>(stream: StringStream, state: HelmState<Y>): string | null {
if (state.inRawString) {
const end = stream.string.indexOf("`", stream.pos);
if (end < 0) {
stream.skipToEnd();
return "string";
}
stream.pos = end + 1;
state.inRawString = false;
return "string";
}
if (stream.eatSpace()) return null;
const rest = stream.string.slice(stream.pos);
const close = CLOSE.exec(rest);
if (close?.index === 0) {
stream.pos += close[0].length;
state.inAction = false;
return "helmDelimiter";
}
const ch = stream.peek();
if (ch === "`") {
stream.next();
state.inRawString = true;
return tokenAction(stream, state);
}
if (ch === '"') {
stream.match(/^"(?:[^"\\]|\\.)*"?/);
return "string";
}
if (ch === "'") {
stream.match(/^'(?:[^'\\]|\\.)*'?/);
return "string";
}
if (stream.match(/^:=/)) return "operator";
if (ch === "|" || ch === "=" || ch === "(" || ch === ")") {
stream.next();
return ch === "|" ? "operator" : "punctuation";
}
if (ch === "$") {
stream.match(/^\$[A-Za-z0-9_]*/);
return "variableName";
}
if (ch === ".") {
if (stream.match(/^\.[A-Za-z_][A-Za-z0-9_]*/)) return "propertyName";
stream.next();
return "operator";
}
if (stream.match(/^-?(?:0x[0-9a-fA-F]+|\d+(?:\.\d+)?)/)) return "number";
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
const word = stream.current();
if (KEYWORDS.has(word)) return "keyword";
if (ATOMS.has(word)) return "bool";
if (FUNCTIONS.has(word)) return "helmFunc";
return "variableName";
}
stream.next();
return null;
}
export function helmParser<Y>(yaml: StreamParser<Y>): StreamParser<HelmState<Y>> {
const copy = (base: StreamParser<Y>, state: Y): Y =>
base.copyState
? base.copyState(state)
: (Object.fromEntries(
Object.entries(state as object).map(([k, v]) => [k, Array.isArray(v) ? v.slice() : v]),
) as Y);
const withinYaml = (stream: StringStream, end: number, state: HelmState<Y>): string | null => {
if (state.quote) {
const q = state.quote;
while (stream.pos < end) {
const ch = stream.string[stream.pos];
if (ch === "\\" && q === '"' && stream.pos + 1 < end) {
stream.pos += 2;
continue;
}
stream.pos++;
if (ch === q) {
state.quote = null;
break;
}
}
return "string";
}
const ch = stream.string[stream.pos];
if (ch === '"' || ch === "'") {
// Quoted scalar containing `{{ … }}` later on the same line: consume up to `end` or closing quote.
if (end < stream.string.length) {
stream.pos++;
state.quote = ch;
while (stream.pos < end) {
const c = stream.string[stream.pos];
if (c === "\\" && ch === '"' && stream.pos + 1 < end) {
stream.pos += 2;
continue;
}
stream.pos++;
if (c === ch) {
state.quote = null;
break;
}
}
return "string";
}
}
const sub = new StringStream(stream.string.slice(0, end), 2, stream.indentUnit);
sub.pos = stream.pos;
sub.start = stream.start;
const style = yaml.token(sub, state.yaml);
stream.pos = Math.max(sub.pos, stream.pos + (sub.pos === stream.pos ? 1 : 0));
return style;
};
return {
name: "helm-template",
languageData: {
...yaml.languageData,
commentTokens: { line: "#", block: { open: "{{/*", close: "*/}}" } },
},
tokenTable: {
...yaml.tokenTable,
atom: tags.propertyName,
helmDelimiter: tags.special(tags.brace),
helmFunc: tags.function(tags.variableName),
},
startState: (indentUnit) => ({
yaml: yaml.startState!(indentUnit),
inAction: false,
inComment: false,
inRawString: false,
quote: null,
indentUnit,
}),
copyState: (s) => ({ ...s, yaml: copy(yaml, s.yaml) }),
blankLine: (s, unit) => {
s.quote = null;
yaml.blankLine?.(s.yaml, unit);
},
token(stream, state) {
if (stream.sol()) state.quote = null;
if (state.inComment) {
const rest = stream.string.slice(stream.pos);
const close = COMMENT_CLOSE.exec(rest);
if (!close) {
stream.skipToEnd();
return "comment";
}
stream.pos += close.index + close[0].length;
state.inComment = false;
return "comment";
}
if (state.inAction) {
return tokenAction(stream, state);
}
const rest = stream.string.slice(stream.pos);
const open = OPEN.exec(rest);
if (open?.index === 0) {
const comment = COMMENT_OPEN.exec(stream.string.slice(stream.pos + open[0].length));
if (comment) {
stream.pos += open[0].length + comment[0].length;
const close = COMMENT_CLOSE.exec(stream.string.slice(stream.pos));
if (!close) {
stream.skipToEnd();
state.inComment = true;
} else {
stream.pos += close.index + close[0].length;
}
return "comment";
}
stream.pos += open[0].length;
state.inAction = true;
return "helmDelimiter";
}
const end = open ? stream.pos + open.index : stream.string.length;
return withinYaml(stream, end, state);
},
};
}
export async function helmTemplate(): Promise<LanguageSupport> {
const { yaml } = await import("@codemirror/legacy-modes/mode/yaml");
return new LanguageSupport(StreamLanguage.define(helmParser(yaml)));
}
+6
View File
@@ -137,6 +137,12 @@ const OWN_MODES = [
extensions: ["djhtml"],
load: () => import("./django").then((m) => m.django()),
}),
LanguageDescription.of({
name: "Helm Template",
alias: ["helm-template", "gotmpl"],
extensions: ["gotmpl"],
load: () => import("./helm").then((m) => m.helmTemplate()),
}),
LanguageDescription.of({
name: "Astro",
extensions: ["astro"],
+1 -1
View File
@@ -19,7 +19,7 @@ const INSPECT_DELAY = 500;
* Dialects the grammar doesn't follow: the database support checks SQL, and Django templates
* borrow the HTML grammar, which reads `{% if a > b %}` as broken markup.
*/
const SKIPPED = new Set(["sql", "plaintext", "django-html"]);
const SKIPPED = new Set(["sql", "plaintext", "django-html", "helm-template"]);
const RUNNING: ReadonlySet<ServerStatus> = new Set<ServerStatus>(["starting", "ready", "busy"]);
let checked: Promise<ReadonlySet<string>> | null = null;
+1
View File
@@ -23,6 +23,7 @@ const cases = [
["JSP", "<% if (ready) { %><p><%= value %></p><% } %>"],
["Angular Template", "@if (ready) {\n <p>{{ value }}</p>\n}"],
["Django Template", "{% if ready %}\n <p>{{ value|lower }}</p>\n{% endif %}"],
["Helm Template", "{{- range .Values.apps }}\napiVersion: v1\nmetadata:\n name: {{ .name | quote }}\n{{- end }}"],
];
function highlights(state: EditorState) {
+1 -1
View File
@@ -1057,7 +1057,7 @@ const EXT_ICONS = table({
"file-json": "json jsonc json5 jsonl ndjson geojson topojson webmanifest har avsc code-workspace code-snippets map",
"file-md": "md mdx markdown mdown mkd mkdn mdwn ronn workbook",
"file-rst": "rst",
"file-yaml": "yaml yml",
"file-yaml": "yaml yml gotmpl",
"file-toml": "toml",
"file-text": "txt text",
"file-log": "log",