UNPKG

langium

Version:

A language engineering tool for the Language Server Protocol

863 lines (783 loc) 40.6 kB
/****************************************************************************** * 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(); } }