Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support users to replace parser rules which are used only as type for cross-references by type declarations #1391

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
80 changes: 72 additions & 8 deletions packages/langium/src/grammar/lsp/grammar-code-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,25 @@
******************************************************************************/

import type { Diagnostic } from 'vscode-languageserver';
import { CodeActionKind } from 'vscode-languageserver';
import type { CodeActionParams } from 'vscode-languageserver-protocol';
import type { CodeAction, Command, Position, TextEdit } from 'vscode-languageserver-types';
import type { URI } from '../../utils/uri-utils.js';
import * as ast from '../../languages/generated/ast.js';
import type { CodeActionProvider } from '../../lsp/code-action.js';
import type { LangiumServices } from '../../lsp/lsp-services.js';
import type { AstReflection, Reference, ReferenceInfo } from '../../syntax-tree.js';
import type { MaybePromise } from '../../utils/promise-utils.js';
import type { LinkingErrorData } from '../../validation/document-validator.js';
import type { DiagnosticData } from '../../validation/validation-registry.js';
import type { LangiumDocument } from '../../workspace/documents.js';
import type { IndexManager } from '../../workspace/index-manager.js';
import { CodeActionKind } from 'vscode-languageserver';
import { getContainerOfType } from '../../utils/ast-utils.js';
import { findLeafNodeAtOffset } from '../../utils/cst-utils.js';
import { findNodeForProperty } from '../../utils/grammar-utils.js';
import type { MaybePromise } from '../../utils/promise-utils.js';
import { escapeRegExp } from '../../utils/regexp-utils.js';
import type { URI } from '../../utils/uri-utils.js';
import { UriUtils } from '../../utils/uri-utils.js';
import type { LinkingErrorData } from '../../validation/document-validator.js';
import { DocumentValidator } from '../../validation/document-validator.js';
import * as ast from '../../languages/generated/ast.js';
import type { DiagnosticData } from '../../validation/validation-registry.js';
import type { LangiumDocument } from '../../workspace/documents.js';
import type { IndexManager } from '../../workspace/index-manager.js';
import { IssueCodes } from '../validation/validator.js';

export class LangiumGrammarCodeActionProvider implements CodeActionProvider {
Expand Down Expand Up @@ -63,6 +63,9 @@ export class LangiumGrammarCodeActionProvider implements CodeActionProvider {
case IssueCodes.CrossRefTokenSyntax:
accept(this.fixCrossRefSyntax(diagnostic, document));
break;
case IssueCodes.ParserRuleToTypeDecl:
accept(this.replaceParserRuleByTypeDeclaration(diagnostic, document));
break;
case IssueCodes.UnnecessaryFileExtension:
accept(this.fixUnnecessaryFileExtension(diagnostic, document));
break;
Expand Down Expand Up @@ -180,6 +183,67 @@ export class LangiumGrammarCodeActionProvider implements CodeActionProvider {
return undefined;
}

private isRuleReplaceable(rule: ast.ParserRule): boolean {
/** at the moment, only "pure" parser rules are supported:
* - supported are only Alternatives (recursively) and "infers"
* - "returns" is not relevant, since cross-references would not refer to the parser rule, but to its "return type" instead
*/
return !rule.fragment && !rule.entry && rule.parameters.length === 0 && !rule.definesHiddenTokens && !rule.wildcard && !rule.returnType && !rule.dataType;
}
private replaceRule(rule: ast.ParserRule): string {
const type = rule.inferredType ?? rule;
return type.name;
}
private isDefinitionReplaceable(node: ast.AbstractElement): boolean {
if (ast.isRuleCall(node)) {
return node.arguments.length === 0 && ast.isParserRule(node.rule.ref) && this.isRuleReplaceable(node.rule.ref);
}
if (ast.isAlternatives(node)) {
return node.elements.every(child => this.isDefinitionReplaceable(child));
}
return false;
}
private replaceDefinition(node: ast.AbstractElement, requiresBraces: boolean): string {
if (ast.isRuleCall(node) && node.rule.ref) {
return node.rule.ref.name;
}
if (ast.isAlternatives(node)) {
const result = node.elements.map(child => this.replaceDefinition(child, true)).join(' | ');
return requiresBraces && node.elements.length >= 2 ? `(${result})` : result;
}
throw new Error('missing code for ' + node);
}

private replaceParserRuleByTypeDeclaration(diagnostic: Diagnostic, document: LangiumDocument): CodeAction | undefined {
const rootCst = document.parseResult.value.$cstNode;
if (rootCst) {
const offset = document.textDocument.offsetAt(diagnostic.range.start);
const cstNode = findLeafNodeAtOffset(rootCst, offset);
const rule = getContainerOfType(cstNode?.astNode, ast.isParserRule);
if (rule && rule.$cstNode) {
const isRuleReplaceable = this.isRuleReplaceable(rule) && this.isDefinitionReplaceable(rule.definition);
if (isRuleReplaceable) {
const newText = `type ${this.replaceRule(rule)} = ${this.replaceDefinition(rule.definition, false)};`;
return {
title: 'Replace parser rule by type declaration',
kind: CodeActionKind.QuickFix,
diagnostics: [diagnostic],
isPreferred: true,
edit: {
changes: {
[document.textDocument.uri]: [{
range: diagnostic.range,
newText
}]
}
}
};
}
}
}
return undefined;
}

private fixUnnecessaryFileExtension(diagnostic: Diagnostic, document: LangiumDocument): CodeAction {
const end = {...diagnostic.range.end};
end.character -= 1;
Expand Down
21 changes: 15 additions & 6 deletions packages/langium/src/grammar/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import { DiagnosticTag } from 'vscode-languageserver-types';
import { getContainerOfType, streamAllContents } from '../../utils/ast-utils.js';
import { MultiMap } from '../../utils/collections.js';
import { toDocumentSegment } from '../../utils/cst-utils.js';
import { findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, isDataTypeRule, isOptionalCardinality, terminalRegex } from '../../utils/grammar-utils.js';
import { stream } from '../../utils/stream.js';
import { diagnosticData } from '../../validation/validation-registry.js';
import * as ast from '../../languages/generated/ast.js';
import { getTypeNameWithoutError, hasDataTypeReturn, isPrimitiveGrammarType, isStringGrammarType, resolveImport, resolveTransitiveImports } from '../internal-grammar-util.js';
import { typeDefinitionToPropertyType } from '../type-system/type-collector/declared-types.js';
import { flattenPlainType, isPlainReferenceType } from '../type-system/type-collector/plain-types.js';
import { isDataTypeRule, terminalRegex, isOptionalCardinality, findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, getAllRulesUsedForCrossReferences } from '../../utils/grammar-utils.js';

export function registerValidationChecks(services: LangiumGrammarServices): void {
const registry = services.validation.ValidationRegistry;
Expand Down Expand Up @@ -103,6 +103,7 @@ export namespace IssueCodes {
export const UseRegexTokens = 'use-regex-tokens';
export const EntryRuleTokenSyntax = 'entry-rule-token-syntax';
export const CrossRefTokenSyntax = 'cross-ref-token-syntax';
export const ParserRuleToTypeDecl = 'parser-rule-to-type-decl';
export const UnnecessaryFileExtension = 'unnecessary-file-extension';
export const InvalidReturns = 'invalid-returns';
export const InvalidInfers = 'invalid-infers';
Expand Down Expand Up @@ -554,17 +555,25 @@ export class LangiumGrammarValidator {

checkGrammarForUnusedRules(grammar: ast.Grammar, accept: ValidationAcceptor): void {
const reachableRules = getAllReachableRules(grammar, true);
const parserRulesUsedByCrossReferences = getAllRulesUsedForCrossReferences(grammar);

for (const rule of grammar.rules) {
if (ast.isTerminalRule(rule) && rule.hidden || isEmptyRule(rule)) {
continue;
}
if (!reachableRules.has(rule)) {
accept('hint', 'This rule is declared but never referenced.', {
node: rule,
property: 'name',
tags: [DiagnosticTag.Unnecessary]
});
if (ast.isParserRule(rule) && parserRulesUsedByCrossReferences.has(rule)) {
accept('hint', 'This parser rule is not used for parsing, but referenced by cross-references. Consider to replace this rule by a type declaration.', {
node: rule,
data: diagnosticData(IssueCodes.ParserRuleToTypeDecl)
});
} else {
accept('hint', 'This rule is declared but never referenced.', {
node: rule,
property: 'name',
tags: [DiagnosticTag.Unnecessary]
});
}
}
}
}
Expand Down
124 changes: 113 additions & 11 deletions packages/langium/src/test/langium-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@
* terms of the MIT License, which is available in the project root.
******************************************************************************/

import * as assert from 'node:assert';
import { CodeAction } from 'vscode-languageserver';
import type { CompletionItem, CompletionList, Diagnostic, DocumentSymbol, FoldingRange, FormattingOptions, Range, ReferenceParams, SemanticTokensParams, SemanticTokenTypes, TextDocumentIdentifier, TextDocumentPositionParams, WorkspaceSymbol } from 'vscode-languageserver-protocol';
import type { LangiumCoreServices, LangiumSharedCoreServices } from '../services.js';
import type { AstNode, CstNode, Properties } from '../syntax-tree.js';
import { type LangiumDocument, TextDocument } from '../workspace/documents.js';
import type { BuildOptions } from '../workspace/document-builder.js';
import { DiagnosticSeverity, MarkupContent } from 'vscode-languageserver-types';
import { escapeRegExp } from '../utils/regexp-utils.js';
import { URI } from '../utils/uri-utils.js';
import { findNodeForProperty } from '../utils/grammar-utils.js';
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 * as assert from 'node:assert';
import { stream } from '../utils/stream.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 { normalizeEOL } from '../generate/template-string.js';
import type { LangiumServices, LangiumSharedLSPServices } from '../lsp/lsp-services.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 type { BuildOptions } from '../workspace/document-builder.js';
import { TextDocument, type LangiumDocument } from '../workspace/documents.js';

export interface ParseHelperOptions extends BuildOptions {
/**
Expand Down Expand Up @@ -747,3 +748,104 @@ export function expectSemanticToken(tokensWithRanges: DecodedSemanticTokensWithR
});
expectedFunction(result.length, 1, `Expected one token with the specified options but found ${result.length}`);
}

export interface QuickFixResult<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 quick-fixes for validation problems.
* @param services the Langium services for the language with quick fixes
* @returns A function to easily test a single quick-fix 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 quick-fix for this validation problem.
* After applying this quick-fix, 'input' is transformed to 'outputAfterFix'.
*/
export function testQuickFix<T extends AstNode = AstNode>(services: LangiumServices):
(input: string, diagnosticCode: string, outputAfterFix: string | undefined, options?: ParseHelperOptions) => Promise<QuickFixResult<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 quick-fixes 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 quick-fix 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 quick-fix
const edits = expectTruthy(action.edit?.changes![document.textDocument.uri]);
const updatedText = TextDocument.applyEdits(document.textDocument, edits!);

// check the result after applying the quick-fix:
// 1st text is updated as expected
expectedFunction(updatedText, outputAfterFix);
// 2nd the validation diagnostic is gone after the fix
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 quick-fix 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();
}
}
22 changes: 22 additions & 0 deletions packages/langium/src/utils/grammar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ function ruleDfs(rule: ast.AbstractRule, visitedSet: Set<string>, allTerminals:
});
}

/**
* Returns all parser rules which provide types which are used in the grammar as type in cross-references.
* @param grammar the grammar to investigate
* @returns the set of parser rules whose contributed types are used as type in cross-references
*/
export function getAllRulesUsedForCrossReferences(grammar: ast.Grammar): Set<ast.ParserRule> {
const result = new Set<ast.ParserRule>();
streamAllContents(grammar).forEach(node => {
if (ast.isCrossReference(node)) {
// the cross-reference refers directly to a parser rule (without "returns", without "infers")
if (ast.isParserRule(node.type.ref)) {
result.add(node.type.ref);
}
// the cross-reference refers to the explicitly inferred type of a parser rule
if (ast.isInferredType(node.type.ref) && ast.isParserRule(node.type.ref.$container)) {
result.add(node.type.ref.$container);
}
}
});
return result;
}

/**
* Determines the grammar expression used to parse a cross-reference (usually a reference to a terminal rule).
* A cross-reference can declare this expression explicitly in the form `[Type : Terminal]`, but if `Terminal`
Expand Down
Loading
Loading