Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The server supports the following settings supplied by LSP clients:

- `yaml.yamlVersion`: Set default YAML spec version (`1.2` or `1.1`). Defaults to `1.2`.
- `yaml.maxItemsComputed`: The maximum number of document symbols and folding regions computed (limited for performance reasons). Defaults to `5000`.
- `yaml.template`: Mask templating expressions before parsing, so that templated documents such as Helm charts parse as plain YAML instead of reporting syntax errors on every `{{ ... }}`. Set to `helm` to enable, `none` to disable. Defaults to `none`. See [Templated documents](#templated-documents).
- `yaml.format.enable`: Enable/disable the default YAML formatter. Defaults to `true`.
- `yaml.format.singleQuote`: Use single quotes instead of double quotes. Defaults to `false`.
- `yaml.format.bracketSpacing`: Print spaces between brackets in objects. Defaults to `true`.
Expand Down Expand Up @@ -293,6 +294,24 @@ When multiple schema sources or schema-disabling mechanisms apply to the same YA
6. `json/schemaAssociations` notification
7. SchemaStore

## Templated documents

Templating languages that wrap YAML, such as Helm's Go templates, produce files that are not valid YAML until they are rendered. A control-flow line like `{{- if .Values.autoscaling.enabled }}` breaks the document structure, and the parser reports a cascade of syntax errors on every line that follows.

Setting `yaml.template` to `helm` masks `{{ ... }}` expressions with inert text of exactly the same length before the document is parsed:

- A line containing only a template expression becomes a comment, so control flow drops out of the document structure.
- An inline expression such as `replicas: {{ .Values.replicaCount }}` becomes a plain scalar, so the value parses as a string.

Because masking preserves length, all offsets are unchanged, and diagnostics, hover, completion, and symbol ranges continue to point at the real document. Schema validation, duplicate key detection, completion, hover, folding, and document symbols keep working on the untemplated parts of the file.

The setting is off by default. Its limits are worth knowing before enabling it:

- There is no completion, hover, or validation inside `{{ }}`. Masking only stops template syntax from breaking the surrounding document.
- A fully templated value validates as a string, so a schema expecting a number or boolean at that position still reports a type error.
- Expressions that expand to block content, such as `{{ include "chart.labels" . | nindent 4 }}`, and `if`/`else` branches that each define the same key, can still produce false positives. Masking cannot know what a template expands to.
- Formatting is disabled for documents containing template expressions, since the formatter would rewrite the template syntax.

## Adding custom tags

YAML custom tags extend the language with application-specific syntax. Configure custom tags with the `yaml.customTags` setting.
Expand Down
2 changes: 2 additions & 0 deletions src/languageserver/handlers/settingsHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export class SettingsHandler {
flowSequence: settings.yaml.style?.flowSequence ?? 'allow',
};
this.yamlSettings.keyOrdering = settings.yaml.keyOrdering ?? false;
this.yamlSettings.template = settings.yaml.template ?? 'none';
}

this.yamlSettings.schemaConfigurationSettings = [];
Expand Down Expand Up @@ -318,6 +319,7 @@ export class SettingsHandler {
flowSequence: this.yamlSettings.style?.flowSequence,
yamlVersion: this.yamlSettings.yamlVersion,
keyOrdering: this.yamlSettings.keyOrdering,
template: this.yamlSettings.template,
hoverSchemaSource: this.yamlSettings.yamlHoverSchemaSource,
};

Expand Down
95 changes: 95 additions & 0 deletions src/languageservice/parser/templateMasking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Red Hat, Inc. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Length-preserving masking of Go-template expressions so that templated
* documents (Helm charts) parse as plain YAML.
*
* Every replacement has exactly the same length as the original span and
* newlines are never touched, so all document offsets survive and no
* position translation is needed anywhere downstream.
*
* Rules:
* - A line whose content is only template expressions (plus whitespace)
* becomes a comment of identical length. Control-flow lines such as
* `{{- if .Values.enabled }}` and `{{- end }}` drop out of the document
* structure entirely.
* - A line that starts with a control-flow expression but carries other
* content (`{{ if ... }}mug: "true"{{ end }}`) is also commented out:
* the content is conditional, so it cannot be validated as
* always-present, and relocating it would break offset preservation.
* - An inline expression after other content (`foo: {{ .Values.name }}`)
* is replaced by an unquoted filler scalar of identical length, so the
* value parses as a plain string.
* - A template span crossing line boundaries is masked on every line it
* covers, each line classified by the rules above.
* - Expressions inside quotes are already valid YAML; masking them keeps
* the value a string of the same length, so the parse result class is
* unchanged and the function stays context-free.
*/

/** Which template dialect to mask. `none` disables masking entirely. */
export type TemplateMode = 'none' | 'helm';

/** Matches a template span, including across line boundaries; an unclosed
* span is masked to the end of its line. */
const TEMPLATE_SPAN = /\{\{[\s\S]*?\}\}|\{\{[^\n]*$/gm;

/** Filler character for inline expressions. Parses as a plain scalar. */
const INLINE_FILL = 'x';

/** Internal sentinels; these control characters cannot appear in an LSP
* document's text. The control-flow sentinel marks spans like `{{ if }}`
* so pass 2 can tell them apart from value expressions. */
const SENTINEL = '\u0000';
const CONTROL_SENTINEL = '\u0001';
// eslint-disable-next-line no-control-regex
const ANY_SENTINEL = /[\u0000\u0001]/;
// eslint-disable-next-line no-control-regex
const ANY_SENTINEL_GLOBAL = /[\u0000\u0001]/g;

/** Matches control-flow expressions (`{{ if }}`, `{{- end }}`, ...) that
* affect document structure rather than producing a value. */
const CONTROL_FLOW = /^\{\{-?\s*(if|else|end|range|with|define|block|template)\b/;

/**
* Mask Go-template expressions in `text`, preserving length and line
* structure exactly. Returns the input unchanged when it contains no
* template expression.
*/
export function maskTemplates(text: string): string {
if (!text.includes('{{')) {
return text;
}

// Pass 1: replace every character of every template span with a
// sentinel, preserving newlines, so pass 2 can classify lines.
// Control-flow spans get a distinct sentinel.
const masked = text.replace(TEMPLATE_SPAN, (m) => m.replace(/[^\n]/g, CONTROL_FLOW.test(m) ? CONTROL_SENTINEL : SENTINEL));

// Pass 2: per line, decide comment-out vs inline filler.
const lines = masked.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!ANY_SENTINEL.test(line)) {
continue;
}
const templateOnly = line.replace(ANY_SENTINEL_GLOBAL, '').trim().length === 0;
// A line that starts with a control-flow expression but carries other
// content ({{ if ... }}mug: "true"{{ end }}) holds conditional content
// that cannot be validated as always-present; drop the whole line.
const wrappedInControlFlow = line.trimStart().startsWith(CONTROL_SENTINEL);
if (templateOnly || wrappedInControlFlow) {
// Comment out at the first non-space column, preserving both
// indentation and total length.
const indent = line.match(/^[ \t]*/)[0].length;
lines[i] = line.slice(0, indent) + '#' + ' '.repeat(Math.max(0, line.length - indent - 1));
} else {
// Inline expression: same-length filler scalar.
lines[i] = line.replace(ANY_SENTINEL_GLOBAL, INLINE_FILL);
}
}
return lines.join('\n');
}
27 changes: 26 additions & 1 deletion src/languageservice/parser/yaml-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { isArrayEqual } from '../utils/arrUtils';
import { getParent } from '../utils/yamlAstUtils';
import type { TextBuffer } from '../utils/textBuffer';
import { getIndentation } from '../utils/strings';
import type { TemplateMode } from './templateMasking';
import { maskTemplates } from './templateMasking';

/**
* These documents are collected into a final YAMLDocument
Expand Down Expand Up @@ -266,11 +268,23 @@ export class YAMLDocument {
interface YamlCachedDocument {
version: number;
parserOptions: ParserOptions;
templateMode: TemplateMode;
document: YAMLDocument;
}
export class YamlDocuments {
// a mapping of URIs to cached documents
private cache = new Map<string, YamlCachedDocument>();
private templateMode: TemplateMode = 'none';

/**
* The template mode is global rather than a `ParserOptions` field because
* most callers of `getYamlDocument` pass no options at all; keying it off
* the options would make masking depend on which feature filled the cache
* first.
*/
configure(settings: { template?: TemplateMode }): void {
this.templateMode = settings?.template ?? 'none';
}

/**
* Get cached YAMLDocument
Expand All @@ -294,14 +308,24 @@ export class YamlDocuments {
private ensureCache(document: TextDocument, parserOptions: ParserOptions, addRootObject: boolean): void {
const key = document.uri;
if (!this.cache.has(key)) {
this.cache.set(key, { version: -1, document: new YAMLDocument([], []), parserOptions: defaultOptions });
this.cache.set(key, {
version: -1,
document: new YAMLDocument([], []),
parserOptions: defaultOptions,
templateMode: this.templateMode,
});
}
const cacheEntry = this.cache.get(key);
if (
cacheEntry.version !== document.version ||
cacheEntry.templateMode !== this.templateMode ||
(parserOptions.customTags && !isArrayEqual(cacheEntry.parserOptions.customTags, parserOptions.customTags))
) {
let text = document.getText();
// Masking is length-preserving, so every offset below stays valid.
if (this.templateMode === 'helm') {
text = maskTemplates(text);
}
// if text is contains only whitespace wrap all text in object to force schema selection
if (addRootObject && !/\S/.test(text)) {
text = `{${text}}`;
Expand All @@ -310,6 +334,7 @@ export class YamlDocuments {
cacheEntry.document = doc;
cacheEntry.version = document.version;
cacheEntry.parserOptions = parserOptions;
cacheEntry.templateMode = this.templateMode;
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/languageservice/services/yamlFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import * as yamlPlugin from 'prettier/plugins/yaml';
import * as estreePlugin from 'prettier/plugins/estree';
import { format } from 'prettier/standalone';
import type { TextDocument } from 'vscode-languageserver-textdocument';
import type { TemplateMode } from '../parser/templateMasking';

export class YAMLFormatter {
private formatterEnabled = true;
private templateMode: TemplateMode = 'none';

public configure(shouldFormat: LanguageSettings): void {
if (shouldFormat) {
this.formatterEnabled = shouldFormat.format;
this.templateMode = shouldFormat.template ?? 'none';
}
}

Expand All @@ -33,6 +36,12 @@ export class YAMLFormatter {
try {
const text = document.getText();

// Prettier has no notion of template expressions and would rewrite or
// reject them, so a templated document is left untouched.
if (this.templateMode !== 'none' && text.includes('{{')) {
return [];
}

const prettierOptions: Options = {
parser: 'yaml',
plugins: [yamlPlugin, estreePlugin],
Expand Down
8 changes: 8 additions & 0 deletions src/languageservice/yamlLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { doDocumentOnTypeFormatting } from './services/yamlOnTypeFormatting';
import { YamlCodeLens } from './services/yamlCodeLens';
import type { Telemetry } from './telemetry';
import type { YamlVersion } from './parser/yamlParser07';
import type { TemplateMode } from './parser/templateMasking';
import { YamlCompletion } from './services/yamlCompletion';
import { yamlDocumentsCache } from './parser/yaml-documents';
import type { SettingsState } from '../yamlSettings';
Expand Down Expand Up @@ -125,6 +126,12 @@ export interface LanguageSettings {
* Show schema source URI in hover popups. Default is true.
*/
hoverSchemaSource?: boolean;

/**
* Mask templating expressions before parsing, so that templated documents
* parse as plain YAML. Default is `none`.
*/
template?: TemplateMode;
}

export interface WorkspaceContextService {
Expand Down Expand Up @@ -230,6 +237,7 @@ export function getLanguageService(params: {
);
});
}
yamlDocumentsCache.configure(settings);
yamlValidation.configure(settings);
hover.configure(settings);
completer.configure(settings, params.yamlSettings);
Expand Down
3 changes: 3 additions & 0 deletions src/yamlSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { JSONSchema } from './languageservice/jsonSchema';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { CRD_CATALOG_URL, JSON_SCHEMASTORE_URL } from './languageservice/utils/schemaUrls';
import type { YamlVersion } from './languageservice/parser/yamlParser07';
import type { TemplateMode } from './languageservice/parser/templateMasking';

// Client settings interface to grab settings relevant for the language server
export interface Settings {
Expand Down Expand Up @@ -37,6 +38,7 @@ export interface Settings {
flowSequence: 'allow' | 'forbid';
};
keyOrdering: boolean;
template: TemplateMode;
maxItemsComputed: number;
yamlVersion: YamlVersion;
hoverSchemaSource: boolean;
Expand Down Expand Up @@ -104,6 +106,7 @@ export class SettingsState {
flowSequence: 'allow' | 'forbid';
};
keyOrdering = false;
template: TemplateMode = 'none';
maxItemsComputed = 5000;

// File validation helpers
Expand Down
Loading
Loading