diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index a1ee0ae0e..3f68042fd 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -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(); diff --git a/languages/helm-template.toml b/languages/helm-template.toml new file mode 100644 index 000000000..c4a6a3846 --- /dev/null +++ b/languages/helm-template.toml @@ -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"] diff --git a/src/editor/core/cm/helm.test.ts b/src/editor/core/cm/helm.test.ts new file mode 100644 index 000000000..f4b6a0394 --- /dev/null +++ b/src/editor/core/cm/helm.test.ts @@ -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" })); + }); +}); diff --git a/src/editor/core/cm/helm.ts b/src/editor/core/cm/helm.ts new file mode 100644 index 000000000..67ed36501 --- /dev/null +++ b/src/editor/core/cm/helm.ts @@ -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 = { + 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(stream: StringStream, state: HelmState): 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(yaml: StreamParser): StreamParser> { + const copy = (base: StreamParser, 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): 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 { + const { yaml } = await import("@codemirror/legacy-modes/mode/yaml"); + return new LanguageSupport(StreamLanguage.define(helmParser(yaml))); +} diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 752a437af..bd73cd992 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -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"], diff --git a/src/editor/lsp/syntaxErrors.ts b/src/editor/lsp/syntaxErrors.ts index f95559266..9ff062e26 100644 --- a/src/editor/lsp/syntaxErrors.ts +++ b/src/editor/lsp/syntaxErrors.ts @@ -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 = new Set(["starting", "ready", "busy"]); let checked: Promise> | null = null; diff --git a/src/test/syntax.test.ts b/src/test/syntax.test.ts index 3d159e73a..1aea7023e 100644 --- a/src/test/syntax.test.ts +++ b/src/test/syntax.test.ts @@ -23,6 +23,7 @@ const cases = [ ["JSP", "<% if (ready) { %>

<%= value %>

<% } %>"], ["Angular Template", "@if (ready) {\n

{{ value }}

\n}"], ["Django Template", "{% if ready %}\n

{{ value|lower }}

\n{% endif %}"], + ["Helm Template", "{{- range .Values.apps }}\napiVersion: v1\nmetadata:\n name: {{ .name | quote }}\n{{- end }}"], ]; function highlights(state: EditorState) { diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index e7e181477..ddfcc9957 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -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",