mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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"]
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user