langium
Version:
A language engineering tool for the Language Server Protocol
863 lines (783 loc) • 40.6 kB
text/typescript
/******************************************************************************
* Copyright 2021 TypeFox GmbH
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
******************************************************************************/
import * as assert from 'node:assert';
import type { CompletionItem, CompletionList, Diagnostic, DocumentSymbol, FoldingRange, FormattingOptions, Range, ReferenceParams, SemanticTokensParams, SemanticTokenTypes, TextDocumentIdentifier, TextDocumentPositionParams, WorkspaceSymbol } from 'vscode-languageserver-protocol';
import { CodeAction, DiagnosticSeverity, MarkupContent } from 'vscode-languageserver-types';
import { normalizeEOL } from '../generate/template-string.js';
import type { LangiumServices, LangiumSharedLSPServices } from '../lsp/lsp-services.js';
import { SemanticTokensDecoder } from '../lsp/semantic-token-provider.js';
import type { ParserOptions } from '../parser/langium-parser.js';
import type { LangiumCoreServices, LangiumSharedCoreServices } from '../services.js';
import type { AstNode, CstNode, Properties } from '../syntax-tree.js';
import type { AsyncDisposable } from '../utils/disposable.js';
import { Disposable } from '../utils/disposable.js';
import { findNodeForProperty } from '../utils/grammar-utils.js';
import { escapeRegExp } from '../utils/regexp-utils.js';
import { stream } from '../utils/stream.js';
import { URI } from '../utils/uri-utils.js';
import { DocumentValidator } from '../validation/document-validator.js';
import type { BuildOptions } from '../workspace/document-builder.js';
import { TextDocument, type LangiumDocument } from '../workspace/documents.js';
export interface ParseHelperOptions extends BuildOptions {
/**
* Specifies the URI of the generated document. Will use a counter variable if not specified.
*/
documentUri?: string;
/**
* Options passed to the LangiumParser.
*/
parserOptions?: ParserOptions
}
let nextDocumentId = 1;
export function parseHelper<T extends AstNode = AstNode>(services: LangiumCoreServices): (input: string, options?: ParseHelperOptions) => Promise<LangiumDocument<T>> {
const metaData = services.LanguageMetaData;
const documentBuilder = services.shared.workspace.DocumentBuilder;
return async (input, options) => {
const uri = URI.parse(options?.documentUri ?? `file:///${nextDocumentId++}${metaData.fileExtensions[0] ?? ''}`);
const document = services.shared.workspace.LangiumDocumentFactory.fromString<T>(input, uri, options?.parserOptions);
services.shared.workspace.LangiumDocuments.addDocument(document);
await documentBuilder.build([document], options);
return document;
};
}
export type ExpectFunction = (actual: unknown, expected: unknown, message?: string) => void;
const expectedFunction: ExpectFunction = (actual, expected, message) => {
assert.deepStrictEqual(actual, expected, message);
};
export interface ExpectedBase {
/**
* Document content.
* Use `<|>` and `<|...|>` to mark special items that are relevant to the test case.
*/
text: string
/**
* Parse options used to parse the {@link text} property.
*/
parseOptions?: ParseHelperOptions
/**
* String to mark indices for test cases. `<|>` by default.
*/
indexMarker?: string
/**
* String to mark start indices for test cases. `<|` by default.
*/
rangeStartMarker?: string
/**
* String to mark end indices for test cases. `|>` by default.
*/
rangeEndMarker?: string
/**
* Whether to dispose the created documents right after performing the check.
*
* Defaults to `false`.
*/
disposeAfterCheck?: boolean;
}
export interface ExpectedHighlight extends ExpectedBase {
index?: number
rangeIndex?: number | number[]
}
/**
* Testing utility function for the `textDocument/documentHighlight` LSP request
*
* @returns A function that performs the assertion
*/
export function expectHighlight(services: LangiumServices): (input: ExpectedHighlight) => Promise<AsyncDisposable> {
return async input => {
const { output, indices, ranges } = replaceIndices(input);
const document = await parseDocument(services, output);
const highlightProvider = services.lsp.DocumentHighlightProvider;
const highlights = await highlightProvider?.getDocumentHighlight(document, textDocumentPositionParams(document, indices[input.index ?? 0])) ?? [];
const rangeIndex = input.rangeIndex;
if (Array.isArray(rangeIndex)) {
expectedFunction(highlights.length, rangeIndex.length, `Expected ${rangeIndex.length} highlights but received ${highlights.length}`);
for (let i = 0; i < rangeIndex.length; i++) {
const index = rangeIndex[i];
const expectedRange: Range = {
start: document.textDocument.positionAt(ranges[index][0]),
end: document.textDocument.positionAt(ranges[index][1])
};
const range = highlights[i].range;
expectedFunction(range, expectedRange, `Expected range ${rangeToString(expectedRange)} does not match actual range ${rangeToString(range)}`);
}
} else if (typeof rangeIndex === 'number') {
const expectedRange: Range = {
start: document.textDocument.positionAt(ranges[rangeIndex][0]),
end: document.textDocument.positionAt(ranges[rangeIndex][1])
};
expectedFunction(highlights.length, 1, `Expected a single highlight but received ${highlights.length}`);
const range = highlights[0].range;
expectedFunction(range, expectedRange, `Expected range ${rangeToString(expectedRange)} does not match actual range ${rangeToString(range)}`);
} else {
expectedFunction(highlights.length, ranges.length, `Expected ${ranges.length} highlights but received ${highlights.length}`);
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
const expectedRange: Range = {
start: document.textDocument.positionAt(range[0]),
end: document.textDocument.positionAt(range[1])
};
const targetRange = highlights[i].range;
expectedFunction(targetRange, expectedRange, `Expected range ${rangeToString(expectedRange)} does not match actual range ${rangeToString(targetRange)}`);
}
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (input.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export interface ExpectedSymbolsList extends ExpectedBase {
expectedSymbols: Array<string | DocumentSymbol>
symbolToString?: (item: DocumentSymbol) => string
}
export interface ExpectedSymbolsCallback extends ExpectedBase {
assert: (symbols: DocumentSymbol[]) => void;
}
export type ExpectedSymbols = ExpectedSymbolsList | ExpectedSymbolsCallback;
export function expectSymbols(services: LangiumServices): (input: ExpectedSymbols) => Promise<AsyncDisposable> {
return async input => {
const document = await parseDocument(services, input.text, input.parseOptions);
const symbolProvider = services.lsp.DocumentSymbolProvider;
const symbols = await symbolProvider?.getSymbols(document, textDocumentParams(document)) ?? [];
if ('assert' in input && typeof input.assert === 'function') {
input.assert(symbols);
} else if ('expectedSymbols' in input) {
const symbolToString = input.symbolToString ?? (symbol => symbol.name);
const expectedSymbols = input.expectedSymbols;
if (symbols.length === expectedSymbols.length) {
for (let i = 0; i < expectedSymbols.length; i++) {
const expected = expectedSymbols[i];
const item = symbols[i];
if (typeof expected === 'string') {
expectedFunction(symbolToString(item), expected);
} else {
expectedFunction(item, expected);
}
}
} else {
const symbolsMapped = symbols.map((s, i) => expectedSymbols[i] === undefined || typeof expectedSymbols[i] === 'string' ? symbolToString(s) : s);
expectedFunction(symbolsMapped, expectedSymbols, `Expected ${expectedSymbols.length} but found ${symbols.length} symbols in document`);
}
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (input.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export interface ExpectedWorkspaceSymbolsBase {
query?: string
}
export interface ExpectedWorkspaceSymbolsList extends ExpectedWorkspaceSymbolsBase {
expectedSymbols: Array<string | WorkspaceSymbol>;
symbolToString?: (item: WorkspaceSymbol) => string;
}
export interface ExpectedWorkspaceSymbolsCallback extends ExpectedWorkspaceSymbolsBase {
assert: (symbols: WorkspaceSymbol[]) => void;
}
export type ExpectedWorkspaceSymbols = ExpectedWorkspaceSymbolsList | ExpectedWorkspaceSymbolsCallback;
export function expectWorkspaceSymbols(services: LangiumSharedLSPServices): (input: ExpectedWorkspaceSymbols) => Promise<void> {
return async input => {
const symbolProvider = services.lsp.WorkspaceSymbolProvider;
const symbols = await symbolProvider?.getSymbols({
query: input.query ?? ''
}) ?? [];
if ('assert' in input && typeof input.assert === 'function') {
input.assert(symbols);
} else if ('expectedSymbols' in input) {
const symbolToString = input.symbolToString ?? (symbol => symbol.name);
const expectedSymbols = input.expectedSymbols;
if (symbols.length === expectedSymbols.length) {
for (let i = 0; i < expectedSymbols.length; i++) {
const expected = expectedSymbols[i];
const item = symbols[i];
if (typeof expected === 'string') {
expectedFunction(symbolToString(item), expected);
} else {
expectedFunction(item, expected);
}
}
} else {
const symbolsMapped = symbols.map((s, i) => expectedSymbols[i] === undefined || typeof expectedSymbols[i] === 'string' ? symbolToString(s) : s);
expectedFunction(symbolsMapped, expectedSymbols, `Expected ${expectedSymbols.length} but found ${symbols.length} symbols in workspace`);
}
}
};
}
export interface ExpectedFoldings extends ExpectedBase {
assert?: (foldings: FoldingRange[], expected: Array<[number, number]>) => void;
}
export function expectFoldings(services: LangiumServices): (input: ExpectedFoldings) => Promise<AsyncDisposable> {
return async input => {
const { output, ranges } = replaceIndices(input);
const document = await parseDocument(services, output, input.parseOptions);
const foldingRangeProvider = services.lsp.FoldingRangeProvider;
const foldings = await foldingRangeProvider?.getFoldingRanges(document, textDocumentParams(document)) ?? [];
foldings.sort((a, b) => a.startLine - b.startLine);
if ('assert' in input && typeof input.assert === 'function') {
input.assert(foldings, ranges);
} else {
expectedFunction(foldings.length, ranges.length, `Expected ${ranges.length} but received ${foldings.length} foldings`);
for (let i = 0; i < ranges.length; i++) {
const expected = ranges[i];
const item = foldings[i];
const expectedStart = document.textDocument.positionAt(expected[0]);
const expectedEnd = document.textDocument.positionAt(expected[1]);
expectedFunction(item.startLine, expectedStart.line, `Expected folding start at line ${expectedStart.line} but received folding start at line ${item.startLine} instead.`);
expectedFunction(item.endLine, expectedEnd.line, `Expected folding end at line ${expectedEnd.line} but received folding end at line ${item.endLine} instead.`);
}
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (input.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export function textDocumentParams(document: LangiumDocument): { textDocument: TextDocumentIdentifier } {
return { textDocument: { uri: document.textDocument.uri } };
}
export interface ExpectedCompletionItems extends ExpectedBase {
index: number
expectedItems: Array<string | CompletionItem>
itemToString?: (item: CompletionItem) => string
}
export interface ExpectedCompletionCallback extends ExpectedBase {
index: number;
assert: (completions: CompletionList) => void;
}
export type ExpectedCompletion = ExpectedCompletionItems | ExpectedCompletionCallback;
export function expectCompletion(services: LangiumServices): (expectedCompletion: ExpectedCompletion) => Promise<AsyncDisposable> {
return async expectedCompletion => {
const { output, indices } = replaceIndices(expectedCompletion);
const document = await parseDocument(services, output, expectedCompletion.parseOptions);
const completionProvider = services.lsp.CompletionProvider;
const offset = indices[expectedCompletion.index];
const completions = await completionProvider?.getCompletion(document, textDocumentPositionParams(document, offset)) ?? { isIncomplete: false, items: [] };
if ('assert' in expectedCompletion && typeof expectedCompletion.assert === 'function') {
expectedCompletion.assert(completions);
} else if ('expectedItems' in expectedCompletion) {
const itemToString = expectedCompletion.itemToString ?? (completion => completion.label);
const expectedItems = expectedCompletion.expectedItems;
const items = completions.items.sort((a, b) => a.sortText?.localeCompare(b.sortText || '0') || 0);
if (items.length === expectedItems.length) {
for (let i = 0; i < expectedItems.length; i++) {
const expected = expectedItems[i];
const completion = items[i];
if (typeof expected === 'string') {
expectedFunction(itemToString(completion), expected);
} else {
expectedFunction(completion, expected);
}
}
} else {
const itemsMapped = items.map((s, i) => expectedItems[i] === undefined || typeof expectedItems[i] === 'string' ? itemToString(s) : s);
expectedFunction(itemsMapped, expectedItems, `Expected ${expectedItems.length} but received ${items.length} completion items`);
}
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (expectedCompletion.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export interface ExpectedGoToDefinition extends ExpectedBase {
index: number,
rangeIndex: number | number[]
}
export function expectGoToDefinition(services: LangiumServices): (expectedGoToDefinition: ExpectedGoToDefinition) => Promise<AsyncDisposable> {
return async expectedGoToDefinition => {
const { output, indices, ranges } = replaceIndices(expectedGoToDefinition);
const document = await parseDocument(services, output, expectedGoToDefinition.parseOptions);
const definitionProvider = services.lsp.DefinitionProvider;
const locationLinks = await definitionProvider?.getDefinition(document, textDocumentPositionParams(document, indices[expectedGoToDefinition.index])) ?? [];
const rangeIndex = expectedGoToDefinition.rangeIndex;
if (Array.isArray(rangeIndex)) {
expectedFunction(locationLinks.length, rangeIndex.length, `Expected ${rangeIndex.length} definitions but received ${locationLinks.length}`);
for (const index of rangeIndex) {
const expectedRange: Range = {
start: document.textDocument.positionAt(ranges[index][0]),
end: document.textDocument.positionAt(ranges[index][1])
};
const range = locationLinks[0].targetSelectionRange;
expectedFunction(range, expectedRange, `Expected range ${rangeToString(expectedRange)} does not match actual range ${rangeToString(range)}`);
}
} else {
const expectedRange: Range = {
start: document.textDocument.positionAt(ranges[rangeIndex][0]),
end: document.textDocument.positionAt(ranges[rangeIndex][1])
};
expectedFunction(locationLinks.length, 1, `Expected a single definition but received ${locationLinks.length}`);
const range = locationLinks[0].targetSelectionRange;
expectedFunction(range, expectedRange, `Expected range ${rangeToString(expectedRange)} does not match actual range ${rangeToString(range)}`);
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (expectedGoToDefinition.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export interface ExpectedFindReferences extends ExpectedBase {
includeDeclaration: boolean
}
export function expectFindReferences(services: LangiumServices): (expectedFindReferences: ExpectedFindReferences) => Promise<AsyncDisposable> {
return async expectedFindReferences => {
const { output, indices, ranges } = replaceIndices(expectedFindReferences);
const document = await parseDocument(services, output, expectedFindReferences.parseOptions);
const expectedRanges: Range[] = ranges.map(range => ({
start: document.textDocument.positionAt(range[0]),
end: document.textDocument.positionAt(range[1])
}));
const referenceFinder = services.lsp.ReferencesProvider;
for (const index of indices) {
const referenceParameters = referenceParams(document, index, expectedFindReferences.includeDeclaration);
const references = await referenceFinder?.findReferences(document, referenceParameters) ?? [];
expectedFunction(references.length, expectedRanges.length, 'Found references do not match amount of expected references');
for (const reference of references) {
expectedFunction(expectedRanges.some(range => isRangeEqual(range, reference.range)), true, `Found unexpected reference at range ${rangeToString(reference.range)}`);
}
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (expectedFindReferences.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export function referenceParams(document: LangiumDocument, offset: number, includeDeclaration: boolean): ReferenceParams {
return {
textDocument: { uri: document.textDocument.uri },
position: document.textDocument.positionAt(offset),
context: { includeDeclaration }
};
}
export interface ExpectedHover extends ExpectedBase {
index: number
hover?: string | RegExp
}
export function expectHover(services: LangiumServices): (expectedHover: ExpectedHover) => Promise<AsyncDisposable> {
return async expectedHover => {
const { output, indices } = replaceIndices(expectedHover);
const document = await parseDocument(services, output, expectedHover.parseOptions);
const hoverProvider = services.lsp.HoverProvider;
const hover = await hoverProvider?.getHoverContent(document, textDocumentPositionParams(document, indices[expectedHover.index]));
const hoverContent = hover && MarkupContent.is(hover.contents) ? hover.contents.value : undefined;
if (typeof expectedHover.hover !== 'object') {
expectedFunction(hoverContent, expectedHover.hover);
} else {
const value = hoverContent ?? '';
expectedFunction(
expectedHover.hover.test(value),
true,
`Hover '${value}' does not match regex /${expectedHover.hover.source}/${expectedHover.hover.flags}.`
);
}
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (expectedHover.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export interface ExpectFormatting {
/**
* Document content before formatting.
*/
before: string
/**
* Expected document content after formatting.
* The test case will compare this to the actual formatted document.
*/
after: string
/**
* Parse options used to parse the {@link text} property.
*/
parseOptions?: ParseHelperOptions
/**
* If given, only the specified range will be affected by the formatter
*/
range?: Range
/**
* Options used by the formatter. Default:
* ```ts
* {
* insertSpaces: true,
* tabSize: 4
* }
* ```
*/
options?: FormattingOptions
/**
* Whether to dispose the created documents right after performing the check.
*
* Defaults to `false`.
*/
disposeAfterCheck?: boolean;
}
export function expectFormatting(services: LangiumServices): (expectedFormatting: ExpectFormatting) => Promise<AsyncDisposable> {
const formatter = services.lsp.Formatter;
if (!formatter) {
throw new Error(`No formatter registered for language ${services.LanguageMetaData.languageId}`);
}
return async expectedFormatting => {
const document = await parseDocument(services, expectedFormatting.before, expectedFormatting.parseOptions);
const identifier = { uri: document.uri.toString() };
const options = expectedFormatting.options ?? {
insertSpaces: true,
tabSize: 4
};
const edits = await (expectedFormatting.range ?
formatter.formatDocumentRange(document, { options, textDocument: identifier, range: expectedFormatting.range }) :
formatter.formatDocument(document, { options, textDocument: identifier }));
const editedDocument = TextDocument.applyEdits(document.textDocument, edits);
expectedFunction(normalizeEOL(editedDocument), normalizeEOL(expectedFormatting.after));
const disposable = Disposable.create(() => clearDocuments(services, [document]));
if (expectedFormatting.disposeAfterCheck) {
await disposable.dispose();
}
return disposable;
};
}
export function textDocumentPositionParams(document: LangiumDocument, offset: number): TextDocumentPositionParams {
return { textDocument: { uri: document.textDocument.uri }, position: document.textDocument.positionAt(offset) };
}
export async function parseDocument<T extends AstNode = AstNode>(services: LangiumCoreServices, input: string, options?: ParseHelperOptions): Promise<LangiumDocument<T>> {
const document = await parseHelper<T>(services)(input, options);
if (!document.parseResult) {
throw new Error('Could not parse document');
}
return document;
}
export function replaceIndices(base: ExpectedBase): { output: string, indices: number[], ranges: Array<[number, number]> } {
const indices: number[] = [];
const ranges: Array<[number, number]> = [];
const rangeStack: number[] = [];
const indexMarker = base.indexMarker || '<|>';
const rangeStartMarker = base.rangeStartMarker || '<|';
const rangeEndMarker = base.rangeEndMarker || '|>';
const regex = new RegExp(`${escapeRegExp(indexMarker)}|${escapeRegExp(rangeStartMarker)}|${escapeRegExp(rangeEndMarker)}`);
let matched = true;
let input = base.text;
while (matched) {
const regexMatch = regex.exec(input);
if (regexMatch) {
const matchedString = regexMatch[0];
switch (matchedString) {
case indexMarker:
indices.push(regexMatch.index);
break;
case rangeStartMarker:
rangeStack.push(regexMatch.index);
break;
case rangeEndMarker: {
const rangeStart = rangeStack.pop() || 0;
ranges.push([rangeStart, regexMatch.index]);
break;
}
}
input = input.substring(0, regexMatch.index) + input.substring(regexMatch.index + matchedString.length);
} else {
matched = false;
}
}
return { output: input, indices, ranges: ranges.sort((a, b) => a[0] - b[0]) };
}
export interface ValidationResult<T extends AstNode = AstNode> extends AsyncDisposable {
diagnostics: Diagnostic[];
document: LangiumDocument<T>;
}
export type ValidationHelperOptions = ParseHelperOptions & { failOnParsingErrors?: boolean };
export function validationHelper<T extends AstNode = AstNode>(services: LangiumCoreServices): (input: string, options?: ValidationHelperOptions) => Promise<ValidationResult<T>> {
const parse = parseHelper<T>(services);
return async (input, options) => {
const parseOptions: ValidationHelperOptions = {
...(options ?? {}),
};
parseOptions.validation ??= true;
const document = await parse(input, parseOptions);
const result = {
document,
diagnostics: document.diagnostics ?? [],
dispose: () => clearDocuments(services, [document])
};
if (options?.failOnParsingErrors) {
expectNoIssues(result, {
severity: DiagnosticSeverity.Error,
data: {
code: DocumentValidator.ParsingError,
},
});
}
return result;
};
}
export type ExpectDiagnosticOptionsWithoutContent<T extends AstNode = AstNode> = ExpectDiagnosticCode & ExpectDiagnosticData & (ExpectDiagnosticAstOptions<T> | ExpectDiagnosticRangeOptions | ExpectDiagnosticOffsetOptions);
export type ExpectDiagnosticOptions<T extends AstNode = AstNode> = ExpectDiagnosticContent & ExpectDiagnosticOptionsWithoutContent<T>;
export interface ExpectDiagnosticContent {
message?: string | RegExp
severity?: DiagnosticSeverity
}
export interface ExpectDiagnosticCode {
code?: string
}
export interface ExpectDiagnosticData {
data?: unknown
}
export interface ExpectDiagnosticAstOptions<T extends AstNode> {
node?: T
property?: Properties<T> | { name: Properties<T>, index?: number }
}
export interface ExpectDiagnosticRangeOptions {
range: Range
}
export interface ExpectDiagnosticOffsetOptions {
offset: number
length: number
}
export type Predicate<T> = (arg: T) => boolean;
export function isDiagnosticDataEqual(lhs: unknown, rhs: unknown): boolean {
if (lhs === rhs) {
return true;
}
if (typeof lhs === 'object' && lhs !== null && typeof rhs === 'object' && rhs !== null) {
for (const key of Object.keys(rhs)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!isDiagnosticDataEqual((lhs as any)[key], (rhs as any)[key])) {
return false;
}
}
return true;
}
return false;
}
export function isRangeEqual(lhs: Range, rhs: Range): boolean {
return lhs.start.character === rhs.start.character
&& lhs.start.line === rhs.start.line
&& lhs.end.character === rhs.end.character
&& lhs.end.line === rhs.end.line;
}
export function rangeToString(range: Range): string {
return `${range.start.line}:${range.start.character}--${range.end.line}:${range.end.character}`;
}
export function filterByOptions<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, options: ExpectDiagnosticOptions<N>) {
const filters: Array<Predicate<Diagnostic>> = [];
if ('node' in options && options.node) {
let cstNode: CstNode | undefined = options.node.$cstNode;
if (options.property) {
const name = typeof options.property === 'string' ? options.property : options.property.name;
const index = typeof options.property === 'string' ? undefined : options.property.index;
cstNode = findNodeForProperty(cstNode, name, index);
}
if (!cstNode) {
throw new Error('Cannot find the node!');
}
filters.push(d => isRangeEqual(cstNode!.range, d.range));
}
if ('offset' in options) {
const outer = {
start: validationResult.document.textDocument.positionAt(options.offset),
end: validationResult.document.textDocument.positionAt(options.offset + options.length)
};
filters.push(d => isRangeEqual(outer, d.range));
}
if ('range' in options) {
filters.push(d => isRangeEqual(options.range!, d.range));
}
if (options.code) {
filters.push(d => d.code === options.code);
}
if (options.data) {
filters.push(d => isDiagnosticDataEqual(d.data, options.data));
}
if (options.message) {
if (typeof options.message === 'string') {
filters.push(d => d.message === options.message);
} else if (options.message instanceof RegExp) {
const regexp = options.message as RegExp;
filters.push(d => regexp.test(d.message));
}
}
if (options.severity) {
filters.push(d => d.severity === options.severity);
}
return validationResult.diagnostics.filter(diag => filters.every(holdsFor => holdsFor(diag)));
}
export function expectNoIssues<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, filterOptions?: ExpectDiagnosticOptions<N>): void {
const filtered = filterOptions ? filterByOptions<T, N>(validationResult, filterOptions) : validationResult.diagnostics;
expectedFunction(filtered.length, 0, `Expected no issues in '${validationResult.document.uri.toString()}', but found ${filtered.length}:\n${printDiagnostics(filtered)}`);
}
export function expectIssue<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, filterOptions?: ExpectDiagnosticOptions<N>): void {
const filtered = filterOptions ? filterByOptions<T, N>(validationResult, filterOptions) : validationResult.diagnostics;
expectedFunction(filtered.length > 0, true, `Found no issues in '${validationResult.document.uri.toString()}'`);
}
export function expectError<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, message: string | RegExp, filterOptions: ExpectDiagnosticOptionsWithoutContent<N>): void {
const content: ExpectDiagnosticContent = {
message,
severity: DiagnosticSeverity.Error
};
expectIssue<T, N>(validationResult, {
...filterOptions,
...content,
});
}
export function expectWarning<T extends AstNode = AstNode, N extends AstNode = AstNode>(validationResult: ValidationResult<T>, message: string | RegExp, filterOptions: ExpectDiagnosticOptionsWithoutContent<N>): void {
const content: ExpectDiagnosticContent = {
message,
severity: DiagnosticSeverity.Warning
};
expectIssue<T, N>(validationResult, {
...filterOptions,
...content,
});
}
export function printDiagnostics(diagnostics: Diagnostic[] | undefined): string {
return diagnostics?.map(d => `line ${d.range.start.line}, column ${d.range.start.character}: ${d.message}`).join('\n') ?? '';
}
/**
* Add the given document to the `TextDocuments` service, simulating it being opened in an editor.
*
* @deprecated Since 3.2.0. Use `set`/`delete` from `TextDocuments` instead.
*/
export function setTextDocument(services: LangiumServices | LangiumSharedLSPServices, document: TextDocument): Disposable {
const shared = 'shared' in services ? services.shared : services;
const textDocuments = shared.workspace.TextDocuments;
textDocuments.set(document);
return Disposable.create(() => {
textDocuments.delete(document.uri);
});
}
export function clearDocuments(services: LangiumCoreServices | LangiumSharedCoreServices, documents?: LangiumDocument[]): Promise<void> {
const shared = 'shared' in services ? services.shared : services;
const allDocs = (documents ? stream(documents) : shared.workspace.LangiumDocuments.all).map(x => x.uri).toArray();
return shared.workspace.DocumentBuilder.update([], allDocs);
}
export interface DecodedSemanticTokensWithRanges {
tokens: SemanticTokensDecoder.DecodedSemanticToken[];
ranges: Array<[number, number]>;
}
export function highlightHelper<T extends AstNode = AstNode>(services: LangiumServices): (input: string, options?: ParseHelperOptions) => Promise<DecodedSemanticTokensWithRanges> {
const parse = parseHelper<T>(services);
const tokenProvider = services.lsp.SemanticTokenProvider;
if (!tokenProvider) {
throw new Error('No semantic token provider provided!');
}
return async (text, options) => {
const { output: input, ranges } = replaceIndices({
text
});
const document = await parse(input, options);
const params: SemanticTokensParams = { textDocument: { uri: document.textDocument.uri } };
const tokens = await tokenProvider.semanticHighlight(document, params);
return { tokens: SemanticTokensDecoder.decode(tokens, tokenProvider.tokenTypes, document), ranges };
};
}
export interface DecodedTokenOptions {
rangeIndex?: number;
tokenType: SemanticTokenTypes;
}
export function expectSemanticToken(tokensWithRanges: DecodedSemanticTokensWithRanges, options: DecodedTokenOptions): void {
const range = tokensWithRanges.ranges[options.rangeIndex || 0];
const result = tokensWithRanges.tokens.filter(t => {
return t.tokenType === options.tokenType && t.offset === range[0] && t.offset + t.text.length === range[1];
});
expectedFunction(result.length, 1, `Expected one token with the specified options but found ${result.length}`);
}
export interface CodeActionResult<T extends AstNode = AstNode> extends AsyncDisposable {
/** the document containing the AST */
document: LangiumDocument<T>;
/** all diagnostics of the validation */
diagnosticsAll: Diagnostic[];
/** the relevant Diagnostic with the given diagnosticCode, it is expected that the given input has exactly one such diagnostic */
diagnosticRelevant: Diagnostic;
/** the CodeAction to fix the found relevant problem, it is possible, that there is no such code action */
action?: CodeAction;
}
/**
* This is a helper function to easily test code actions (quick-fixes) for validation problems.
* @param services the Langium services for the language with code actions
* @returns A function to easily test a single code action on the given invalid 'input'.
* This function expects, that 'input' contains exactly one validation problem with the given 'diagnosticCode'.
* If 'outputAfterFix' is specified, this functions checks, that the diagnostic comes with a single code action for this validation problem.
* After applying this code action, 'input' is transformed to 'outputAfterFix'.
*/
export function testCodeAction<T extends AstNode = AstNode>(services: LangiumServices): (input: string, diagnosticCode: string, outputAfterFix: string | undefined, options?: ParseHelperOptions) => Promise<CodeActionResult<T>> {
const validateHelper = validationHelper<T>(services);
return async (input, diagnosticCode, outputAfterFix, options) => {
// parse + validate
const validationBefore = await validateHelper(input, options);
const document = validationBefore.document;
const diagnosticsAll = document.diagnostics ?? [];
// use only the diagnostics with the given validation code
const diagnosticsRelevant = diagnosticsAll.filter(d => d.data && 'code' in d.data && d.data.code === diagnosticCode);
// expect exactly one validation with the given code
expectedFunction(diagnosticsRelevant.length, 1);
const diagnosticRelevant = diagnosticsRelevant[0];
// check, that the code actions are generated for the selected validation:
// prepare the action provider
const actionProvider = expectTruthy(services.lsp.CodeActionProvider);
// request the actions for this diagnostic
const currentActions = await actionProvider!.getCodeActions(document, {
...textDocumentParams(document),
range: diagnosticRelevant.range,
context: {
diagnostics: diagnosticsRelevant,
triggerKind: 1 // explicitly triggered by users (or extensions)
}
});
// evaluate the resulting actions
let action: CodeAction | undefined;
let validationAfter: ValidationResult | undefined;
if (outputAfterFix) {
// exactly one code action is expected
expectTruthy(currentActions);
expectTruthy(Array.isArray(currentActions));
expectedFunction(currentActions!.length, 1);
expectTruthy(CodeAction.is(currentActions![0]));
action = currentActions![0] as CodeAction;
// execute the found code action
const edits = expectTruthy(action.edit?.changes![document.textDocument.uri]);
const updatedText = TextDocument.applyEdits(document.textDocument, edits!);
// check the result after applying the code action:
// 1st text is updated as expected
expectedFunction(updatedText, outputAfterFix);
// 2nd the validation diagnostic is gone after applying the code action
validationAfter = await validateHelper(updatedText, options);
const diagnosticsUpdated = validationAfter.diagnostics.filter(d => d.data && 'code' in d.data && d.data.code === diagnosticCode);
expectedFunction(diagnosticsUpdated.length, 0);
} else {
// no code action is expected
expectFalsy(currentActions);
}
// collect the data to return
async function dispose(): Promise<void> {
validationBefore.dispose();
validationAfter?.dispose();
}
return {
document,
diagnosticsAll,
diagnosticRelevant: diagnosticRelevant,
action,
dispose: () => dispose()
};
};
}
function expectTruthy<T>(value: T): NonNullable<T> {
if (value) {
return value;
} else {
throw new Error();
}
}
function expectFalsy(value: unknown) {
if (value) {
throw new Error();
}
}