Skip to content

Commit efb6c9e

Browse files
Add code actions to replace parser rules with type declarations (#1391)
1 parent f3029bf commit efb6c9e

File tree

6 files changed

+343
-21
lines changed

6 files changed

+343
-21
lines changed

packages/langium/src/grammar/lsp/grammar-code-actions.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,25 @@
55
******************************************************************************/
66

77
import type { Diagnostic } from 'vscode-languageserver';
8+
import { CodeActionKind } from 'vscode-languageserver';
89
import type { CodeActionParams } from 'vscode-languageserver-protocol';
910
import type { CodeAction, Command, Position, TextEdit } from 'vscode-languageserver-types';
10-
import type { URI } from '../../utils/uri-utils.js';
11+
import * as ast from '../../languages/generated/ast.js';
1112
import type { CodeActionProvider } from '../../lsp/code-action.js';
1213
import type { LangiumServices } from '../../lsp/lsp-services.js';
1314
import type { AstReflection, Reference, ReferenceInfo } from '../../syntax-tree.js';
14-
import type { MaybePromise } from '../../utils/promise-utils.js';
15-
import type { LinkingErrorData } from '../../validation/document-validator.js';
16-
import type { DiagnosticData } from '../../validation/validation-registry.js';
17-
import type { LangiumDocument } from '../../workspace/documents.js';
18-
import type { IndexManager } from '../../workspace/index-manager.js';
19-
import { CodeActionKind } from 'vscode-languageserver';
2015
import { getContainerOfType } from '../../utils/ast-utils.js';
2116
import { findLeafNodeAtOffset } from '../../utils/cst-utils.js';
2217
import { findNodeForProperty } from '../../utils/grammar-utils.js';
18+
import type { MaybePromise } from '../../utils/promise-utils.js';
2319
import { escapeRegExp } from '../../utils/regexp-utils.js';
20+
import type { URI } from '../../utils/uri-utils.js';
2421
import { UriUtils } from '../../utils/uri-utils.js';
22+
import type { LinkingErrorData } from '../../validation/document-validator.js';
2523
import { DocumentValidator } from '../../validation/document-validator.js';
26-
import * as ast from '../../languages/generated/ast.js';
24+
import type { DiagnosticData } from '../../validation/validation-registry.js';
25+
import type { LangiumDocument } from '../../workspace/documents.js';
26+
import type { IndexManager } from '../../workspace/index-manager.js';
2727
import { IssueCodes } from '../validation/validator.js';
2828

2929
export class LangiumGrammarCodeActionProvider implements CodeActionProvider {
@@ -63,6 +63,9 @@ export class LangiumGrammarCodeActionProvider implements CodeActionProvider {
6363
case IssueCodes.CrossRefTokenSyntax:
6464
accept(this.fixCrossRefSyntax(diagnostic, document));
6565
break;
66+
case IssueCodes.ParserRuleToTypeDecl:
67+
accept(this.replaceParserRuleByTypeDeclaration(diagnostic, document));
68+
break;
6669
case IssueCodes.UnnecessaryFileExtension:
6770
accept(this.fixUnnecessaryFileExtension(diagnostic, document));
6871
break;
@@ -180,6 +183,66 @@ export class LangiumGrammarCodeActionProvider implements CodeActionProvider {
180183
return undefined;
181184
}
182185

186+
private isRuleReplaceable(rule: ast.ParserRule): boolean {
187+
/** at the moment, only "pure" parser rules are supported:
188+
* - supported are only Alternatives (recursively) and "infers"
189+
* - "returns" is not relevant, since cross-references would not refer to the parser rule, but to its "return type" instead
190+
*/
191+
return !rule.fragment && !rule.entry && rule.parameters.length === 0 && !rule.definesHiddenTokens && !rule.wildcard && !rule.returnType && !rule.dataType;
192+
}
193+
private replaceRule(rule: ast.ParserRule): string {
194+
const type = rule.inferredType ?? rule;
195+
return type.name;
196+
}
197+
private isDefinitionReplaceable(node: ast.AbstractElement): boolean {
198+
if (ast.isRuleCall(node)) {
199+
return node.arguments.length === 0 && ast.isParserRule(node.rule.ref) && this.isRuleReplaceable(node.rule.ref);
200+
}
201+
if (ast.isAlternatives(node)) {
202+
return node.elements.every(child => this.isDefinitionReplaceable(child));
203+
}
204+
return false;
205+
}
206+
private replaceDefinition(node: ast.AbstractElement): string {
207+
if (ast.isRuleCall(node) && node.rule.ref) {
208+
return node.rule.ref.name;
209+
}
210+
if (ast.isAlternatives(node)) {
211+
return node.elements.map(child => this.replaceDefinition(child)).join(' | ');
212+
}
213+
throw new Error('missing code for ' + node);
214+
}
215+
216+
private replaceParserRuleByTypeDeclaration(diagnostic: Diagnostic, document: LangiumDocument): CodeAction | undefined {
217+
const rootCst = document.parseResult.value.$cstNode;
218+
if (rootCst) {
219+
const offset = document.textDocument.offsetAt(diagnostic.range.start);
220+
const cstNode = findLeafNodeAtOffset(rootCst, offset);
221+
const rule = getContainerOfType(cstNode?.astNode, ast.isParserRule);
222+
if (rule && rule.$cstNode) {
223+
const isReplaceable = this.isRuleReplaceable(rule) && this.isDefinitionReplaceable(rule.definition);
224+
if (isReplaceable) {
225+
const newText = `type ${this.replaceRule(rule)} = ${this.replaceDefinition(rule.definition)};`;
226+
return {
227+
title: 'Replace with type declaration',
228+
kind: CodeActionKind.QuickFix,
229+
diagnostics: [diagnostic],
230+
isPreferred: true,
231+
edit: {
232+
changes: {
233+
[document.textDocument.uri]: [{
234+
range: diagnostic.range,
235+
newText
236+
}]
237+
}
238+
}
239+
};
240+
}
241+
}
242+
}
243+
return undefined;
244+
}
245+
183246
private fixUnnecessaryFileExtension(diagnostic: Diagnostic, document: LangiumDocument): CodeAction {
184247
const end = {...diagnostic.range.end};
185248
end.character -= 1;

packages/langium/src/grammar/validation/validator.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { AstNode, Properties, Reference } from '../../syntax-tree.js';
1313
import { getContainerOfType, streamAllContents } from '../../utils/ast-utils.js';
1414
import { MultiMap } from '../../utils/collections.js';
1515
import { toDocumentSegment } from '../../utils/cst-utils.js';
16-
import { findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, isArrayCardinality, isDataTypeRule, isOptionalCardinality, terminalRegex } from '../../utils/grammar-utils.js';
16+
import { findNameAssignment, findNodeForKeyword, findNodeForProperty, getAllReachableRules, getAllRulesUsedForCrossReferences, isArrayCardinality, isDataTypeRule, isOptionalCardinality, terminalRegex } from '../../utils/grammar-utils.js';
1717
import type { Stream } from '../../utils/stream.js';
1818
import { stream } from '../../utils/stream.js';
1919
import type { DiagnosticData, ValidationAcceptor, ValidationChecks } from '../../validation/validation-registry.js';
@@ -107,6 +107,7 @@ export namespace IssueCodes {
107107
export const UseRegexTokens = 'use-regex-tokens';
108108
export const EntryRuleTokenSyntax = 'entry-rule-token-syntax';
109109
export const CrossRefTokenSyntax = 'cross-ref-token-syntax';
110+
export const ParserRuleToTypeDecl = 'parser-rule-to-type-decl';
110111
export const UnnecessaryFileExtension = 'unnecessary-file-extension';
111112
export const InvalidReturns = 'invalid-returns';
112113
export const InvalidInfers = 'invalid-infers';
@@ -605,17 +606,25 @@ export class LangiumGrammarValidator {
605606

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

609611
for (const rule of grammar.rules) {
610612
if (ast.isTerminalRule(rule) && rule.hidden || isEmptyRule(rule)) {
611613
continue;
612614
}
613615
if (!reachableRules.has(rule)) {
614-
accept('hint', 'This rule is declared but never referenced.', {
615-
node: rule,
616-
property: 'name',
617-
tags: [DiagnosticTag.Unnecessary]
618-
});
616+
if (ast.isParserRule(rule) && parserRulesUsedByCrossReferences.has(rule)) {
617+
accept('hint', 'This parser rule is not used for parsing, but referenced by cross-references. Consider to replace this rule by a type declaration.', {
618+
node: rule,
619+
data: diagnosticData(IssueCodes.ParserRuleToTypeDecl)
620+
});
621+
} else {
622+
accept('hint', 'This rule is declared but never referenced.', {
623+
node: rule,
624+
property: 'name',
625+
tags: [DiagnosticTag.Unnecessary]
626+
});
627+
}
619628
}
620629
}
621630
}

packages/langium/src/test/langium-test.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import * as assert from 'node:assert';
88
import type { CompletionItem, CompletionList, Diagnostic, DocumentSymbol, FoldingRange, FormattingOptions, Range, ReferenceParams, SemanticTokensParams, SemanticTokenTypes, TextDocumentIdentifier, TextDocumentPositionParams, WorkspaceSymbol } from 'vscode-languageserver-protocol';
9-
import { DiagnosticSeverity, MarkupContent } from 'vscode-languageserver-types';
9+
import { CodeAction, DiagnosticSeverity, MarkupContent } from 'vscode-languageserver-types';
1010
import { normalizeEOL } from '../generate/template-string.js';
1111
import type { LangiumServices, LangiumSharedLSPServices } from '../lsp/lsp-services.js';
1212
import { SemanticTokensDecoder } from '../lsp/semantic-token-provider.js';
@@ -22,7 +22,6 @@ import { URI } from '../utils/uri-utils.js';
2222
import { DocumentValidator } from '../validation/document-validator.js';
2323
import type { BuildOptions } from '../workspace/document-builder.js';
2424
import { TextDocument, type LangiumDocument } from '../workspace/documents.js';
25-
2625
export interface ParseHelperOptions extends BuildOptions {
2726
/**
2827
* Specifies the URI of the generated document. Will use a counter variable if not specified.
@@ -770,3 +769,103 @@ export function expectSemanticToken(tokensWithRanges: DecodedSemanticTokensWithR
770769
});
771770
expectedFunction(result.length, 1, `Expected one token with the specified options but found ${result.length}`);
772771
}
772+
773+
export interface CodeActionResult<T extends AstNode = AstNode> extends AsyncDisposable {
774+
/** the document containing the AST */
775+
document: LangiumDocument<T>;
776+
/** all diagnostics of the validation */
777+
diagnosticsAll: Diagnostic[];
778+
/** the relevant Diagnostic with the given diagnosticCode, it is expected that the given input has exactly one such diagnostic */
779+
diagnosticRelevant: Diagnostic;
780+
/** the CodeAction to fix the found relevant problem, it is possible, that there is no such code action */
781+
action?: CodeAction;
782+
}
783+
784+
/**
785+
* This is a helper function to easily test code actions (quick-fixes) for validation problems.
786+
* @param services the Langium services for the language with code actions
787+
* @returns A function to easily test a single code action on the given invalid 'input'.
788+
* This function expects, that 'input' contains exactly one validation problem with the given 'diagnosticCode'.
789+
* If 'outputAfterFix' is specified, this functions checks, that the diagnostic comes with a single code action for this validation problem.
790+
* After applying this code action, 'input' is transformed to 'outputAfterFix'.
791+
*/
792+
export function testCodeAction<T extends AstNode = AstNode>(services: LangiumServices): (input: string, diagnosticCode: string, outputAfterFix: string | undefined, options?: ParseHelperOptions) => Promise<CodeActionResult<T>> {
793+
const validateHelper = validationHelper<T>(services);
794+
return async (input, diagnosticCode, outputAfterFix, options) => {
795+
// parse + validate
796+
const validationBefore = await validateHelper(input, options);
797+
const document = validationBefore.document;
798+
const diagnosticsAll = document.diagnostics ?? [];
799+
// use only the diagnostics with the given validation code
800+
const diagnosticsRelevant = diagnosticsAll.filter(d => d.data && 'code' in d.data && d.data.code === diagnosticCode);
801+
// expect exactly one validation with the given code
802+
expectedFunction(diagnosticsRelevant.length, 1);
803+
const diagnosticRelevant = diagnosticsRelevant[0];
804+
805+
// check, that the code actions are generated for the selected validation:
806+
// prepare the action provider
807+
const actionProvider = expectTruthy(services.lsp.CodeActionProvider);
808+
// request the actions for this diagnostic
809+
const currentActions = await actionProvider!.getCodeActions(document, {
810+
...textDocumentParams(document),
811+
range: diagnosticRelevant.range,
812+
context: {
813+
diagnostics: diagnosticsRelevant,
814+
triggerKind: 1 // explicitly triggered by users (or extensions)
815+
}
816+
});
817+
818+
// evaluate the resulting actions
819+
let action: CodeAction | undefined;
820+
let validationAfter: ValidationResult | undefined;
821+
if (outputAfterFix) {
822+
// exactly one code action is expected
823+
expectTruthy(currentActions);
824+
expectTruthy(Array.isArray(currentActions));
825+
expectedFunction(currentActions!.length, 1);
826+
expectTruthy(CodeAction.is(currentActions![0]));
827+
action = currentActions![0] as CodeAction;
828+
829+
// execute the found code action
830+
const edits = expectTruthy(action.edit?.changes![document.textDocument.uri]);
831+
const updatedText = TextDocument.applyEdits(document.textDocument, edits!);
832+
833+
// check the result after applying the code action:
834+
// 1st text is updated as expected
835+
expectedFunction(updatedText, outputAfterFix);
836+
// 2nd the validation diagnostic is gone after applying the code action
837+
validationAfter = await validateHelper(updatedText, options);
838+
const diagnosticsUpdated = validationAfter.diagnostics.filter(d => d.data && 'code' in d.data && d.data.code === diagnosticCode);
839+
expectedFunction(diagnosticsUpdated.length, 0);
840+
} else {
841+
// no code action is expected
842+
expectFalsy(currentActions);
843+
}
844+
845+
// collect the data to return
846+
async function dispose(): Promise<void> {
847+
validationBefore.dispose();
848+
validationAfter?.dispose();
849+
}
850+
return {
851+
document,
852+
diagnosticsAll,
853+
diagnosticRelevant: diagnosticRelevant,
854+
action,
855+
dispose: () => dispose()
856+
};
857+
};
858+
}
859+
860+
function expectTruthy<T>(value: T): NonNullable<T> {
861+
if (value) {
862+
return value;
863+
} else {
864+
throw new Error();
865+
}
866+
}
867+
function expectFalsy(value: unknown) {
868+
if (value) {
869+
throw new Error();
870+
}
871+
}

packages/langium/src/utils/grammar-utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ function ruleDfs(rule: ast.AbstractRule, visitedSet: Set<string>, allTerminals:
6868
});
6969
}
7070

71+
/**
72+
* Returns all parser rules which provide types which are used in the grammar as type in cross-references.
73+
* @param grammar the grammar to investigate
74+
* @returns the set of parser rules whose contributed types are used as type in cross-references
75+
*/
76+
export function getAllRulesUsedForCrossReferences(grammar: ast.Grammar): Set<ast.ParserRule> {
77+
const result = new Set<ast.ParserRule>();
78+
streamAllContents(grammar).forEach(node => {
79+
if (ast.isCrossReference(node)) {
80+
// the cross-reference refers directly to a parser rule (without "returns", without "infers")
81+
if (ast.isParserRule(node.type.ref)) {
82+
result.add(node.type.ref);
83+
}
84+
// the cross-reference refers to the explicitly inferred type of a parser rule
85+
if (ast.isInferredType(node.type.ref) && ast.isParserRule(node.type.ref.$container)) {
86+
result.add(node.type.ref.$container);
87+
}
88+
}
89+
});
90+
return result;
91+
}
92+
7193
/**
7294
* Determines the grammar expression used to parse a cross-reference (usually a reference to a terminal rule).
7395
* A cross-reference can declare this expression explicitly in the form `[Type : Terminal]`, but if `Terminal`

packages/langium/test/grammar/grammar-validator.test.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
* terms of the MIT License, which is available in the project root.
55
******************************************************************************/
66

7-
import type { AstNode, Properties } from 'langium';
8-
import type { GrammarAST as GrammarTypes } from 'langium';
9-
import type { ValidationResult } from 'langium/test';
10-
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
11-
import { DiagnosticSeverity } from 'vscode-languageserver';
7+
import type { AstNode, GrammarAST as GrammarTypes, Properties } from 'langium';
128
import { AstUtils, EmptyFileSystem, GrammarAST } from 'langium';
139
import { IssueCodes, createLangiumGrammarServices } from 'langium/grammar';
10+
import type { ValidationResult } from 'langium/test';
1411
import { clearDocuments, expectError, expectIssue, expectNoIssues, expectWarning, parseHelper, validationHelper } from 'langium/test';
12+
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
13+
import { DiagnosticSeverity } from 'vscode-languageserver';
14+
import { beforeAnotherRule, beforeSinglelternative, beforeTwoAlternatives, beforeWithInfers } from './lsp/grammar-code-actions.test.js';
1515

1616
const services = createLangiumGrammarServices(EmptyFileSystem);
1717
const parse = parseHelper(services.grammar);
@@ -514,6 +514,38 @@ describe('Unused rules validation', () => {
514514

515515
});
516516

517+
describe('Parser rules used only as type in cross-references are not marked as unused, but with a hint suggesting to use a type declaration instead', () => {
518+
// The used test data are defined at the test cases for possible code actions for these validation problems.
519+
// these test cases target https://github.com/eclipse-langium/langium/issues/1309
520+
521+
test('union of two types', async () => {
522+
await validateRule(beforeTwoAlternatives);
523+
});
524+
525+
test('only a single type', async () => {
526+
await validateRule(beforeSinglelternative);
527+
});
528+
529+
test('rule using a nested rule', async () => {
530+
await validateRule(beforeAnotherRule, 2); // 2 hints, since there is another "unused" rule (which is out-of-scope here)
531+
});
532+
533+
test('union of two types, with "infers" keyword', async () => {
534+
await validateRule(beforeWithInfers);
535+
});
536+
537+
async function validateRule(grammar: string, foundDiagnostics: number = 1) {
538+
const validation = await validate(grammar);
539+
expect(validation.diagnostics).toHaveLength(foundDiagnostics);
540+
const ruleWithHint = validation.document.parseResult.value.rules.find(e => e.name === 'Person')!;
541+
expectIssue(validation, {
542+
node: ruleWithHint,
543+
severity: DiagnosticSeverity.Hint
544+
});
545+
return ruleWithHint;
546+
}
547+
});
548+
517549
describe('Reserved names', () => {
518550

519551
test('Reserved parser rule name', async () => {

0 commit comments

Comments
 (0)