mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
419 lines
16 KiB
419 lines
16 KiB
/******************************************************************************
|
|
* 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 { 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 { escapeRegExp } from '../../utils/regexp-utils.js';
|
|
import { UriUtils } from '../../utils/uri-utils.js';
|
|
import { DocumentValidator } from '../../validation/document-validator.js';
|
|
import * as ast from '../../languages/generated/ast.js';
|
|
import { IssueCodes } from '../validation/validator.js';
|
|
export class LangiumGrammarCodeActionProvider {
|
|
constructor(services) {
|
|
this.reflection = services.shared.AstReflection;
|
|
this.indexManager = services.shared.workspace.IndexManager;
|
|
}
|
|
getCodeActions(document, params) {
|
|
const result = [];
|
|
const acceptor = (ca) => ca && result.push(ca);
|
|
for (const diagnostic of params.context.diagnostics) {
|
|
this.createCodeActions(diagnostic, document, acceptor);
|
|
}
|
|
return result;
|
|
}
|
|
createCodeActions(diagnostic, document, accept) {
|
|
var _a;
|
|
switch ((_a = diagnostic.data) === null || _a === void 0 ? void 0 : _a.code) {
|
|
case IssueCodes.GrammarNameUppercase:
|
|
case IssueCodes.RuleNameUppercase:
|
|
accept(this.makeUpperCase(diagnostic, document));
|
|
break;
|
|
case IssueCodes.HiddenGrammarTokens:
|
|
accept(this.fixHiddenTerminals(diagnostic, document));
|
|
break;
|
|
case IssueCodes.UseRegexTokens:
|
|
accept(this.fixRegexTokens(diagnostic, document));
|
|
break;
|
|
case IssueCodes.EntryRuleTokenSyntax:
|
|
accept(this.addEntryKeyword(diagnostic, document));
|
|
break;
|
|
case IssueCodes.CrossRefTokenSyntax:
|
|
accept(this.fixCrossRefSyntax(diagnostic, document));
|
|
break;
|
|
case IssueCodes.UnnecessaryFileExtension:
|
|
accept(this.fixUnnecessaryFileExtension(diagnostic, document));
|
|
break;
|
|
case IssueCodes.MissingReturns:
|
|
accept(this.fixMissingReturns(diagnostic, document));
|
|
break;
|
|
case IssueCodes.InvalidInfers:
|
|
case IssueCodes.InvalidReturns:
|
|
accept(this.fixInvalidReturnsInfers(diagnostic, document));
|
|
break;
|
|
case IssueCodes.MissingInfer:
|
|
accept(this.fixMissingInfer(diagnostic, document));
|
|
break;
|
|
case IssueCodes.SuperfluousInfer:
|
|
accept(this.fixSuperfluousInfer(diagnostic, document));
|
|
break;
|
|
case DocumentValidator.LinkingError: {
|
|
const data = diagnostic.data;
|
|
if (data && data.containerType === 'RuleCall' && data.property === 'rule') {
|
|
accept(this.addNewRule(diagnostic, data, document));
|
|
}
|
|
if (data) {
|
|
this.lookInGlobalScope(diagnostic, data, document).forEach(accept);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Adds missing returns for parser rule
|
|
*/
|
|
fixMissingReturns(diagnostic, document) {
|
|
const text = document.textDocument.getText(diagnostic.range);
|
|
if (text) {
|
|
return {
|
|
title: `Add explicit return type for parser rule ${text}`,
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: diagnostic.range,
|
|
newText: `${text} returns ${text}` // suggestion adds missing 'return'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
fixInvalidReturnsInfers(diagnostic, document) {
|
|
const data = diagnostic.data;
|
|
if (data && data.actionSegment) {
|
|
const text = document.textDocument.getText(data.actionSegment.range);
|
|
return {
|
|
title: `Correct ${text} usage`,
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: data.actionSegment.range,
|
|
newText: text === 'infers' ? 'returns' : 'infers'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
fixMissingInfer(diagnostic, document) {
|
|
const data = diagnostic.data;
|
|
if (data && data.actionSegment) {
|
|
return {
|
|
title: "Correct 'infer' usage",
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: {
|
|
start: data.actionSegment.range.end,
|
|
end: data.actionSegment.range.end
|
|
},
|
|
newText: 'infer '
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
fixSuperfluousInfer(diagnostic, document) {
|
|
const data = diagnostic.data;
|
|
if (data && data.actionRange) {
|
|
return {
|
|
title: "Remove the 'infer' keyword",
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: data.actionRange,
|
|
newText: ''
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
fixUnnecessaryFileExtension(diagnostic, document) {
|
|
const end = Object.assign({}, diagnostic.range.end);
|
|
end.character -= 1;
|
|
const start = Object.assign({}, end);
|
|
start.character -= '.langium'.length;
|
|
return {
|
|
title: 'Remove file extension',
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: true,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: {
|
|
start,
|
|
end
|
|
},
|
|
newText: ''
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
makeUpperCase(diagnostic, document) {
|
|
const range = {
|
|
start: diagnostic.range.start,
|
|
end: {
|
|
line: diagnostic.range.start.line,
|
|
character: diagnostic.range.start.character + 1
|
|
}
|
|
};
|
|
return {
|
|
title: 'First letter to upper case',
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: true,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range,
|
|
newText: document.textDocument.getText(range).toUpperCase()
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
addEntryKeyword(diagnostic, document) {
|
|
return {
|
|
title: 'Add entry keyword',
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: true,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: { start: diagnostic.range.start, end: diagnostic.range.start },
|
|
newText: 'entry '
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
fixRegexTokens(diagnostic, document) {
|
|
const offset = document.textDocument.offsetAt(diagnostic.range.start);
|
|
const rootCst = document.parseResult.value.$cstNode;
|
|
if (rootCst) {
|
|
const cstNode = findLeafNodeAtOffset(rootCst, offset);
|
|
const container = getContainerOfType(cstNode === null || cstNode === void 0 ? void 0 : cstNode.astNode, ast.isCharacterRange);
|
|
if (container && container.right && container.$cstNode) {
|
|
const left = container.left.value;
|
|
const right = container.right.value;
|
|
return {
|
|
title: 'Refactor into regular expression',
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: true,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: container.$cstNode.range,
|
|
newText: `/[${escapeRegExp(left)}-${escapeRegExp(right)}]/`
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
fixCrossRefSyntax(diagnostic, document) {
|
|
return {
|
|
title: "Replace '|' with ':'",
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: true,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: diagnostic.range,
|
|
newText: ':'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
fixHiddenTerminals(diagnostic, document) {
|
|
const grammar = document.parseResult.value;
|
|
const hiddenTokens = grammar.hiddenTokens;
|
|
const changes = [];
|
|
const hiddenNode = findNodeForProperty(grammar.$cstNode, 'definesHiddenTokens');
|
|
if (hiddenNode) {
|
|
const start = hiddenNode.range.start;
|
|
const offset = hiddenNode.offset;
|
|
const end = grammar.$cstNode.text.indexOf(')', offset) + 1;
|
|
changes.push({
|
|
newText: '',
|
|
range: {
|
|
start,
|
|
end: document.textDocument.positionAt(end)
|
|
}
|
|
});
|
|
}
|
|
for (const terminal of hiddenTokens) {
|
|
const ref = terminal.ref;
|
|
if (ref && ast.isTerminalRule(ref) && !ref.hidden && ref.$cstNode) {
|
|
const start = ref.$cstNode.range.start;
|
|
changes.push({
|
|
newText: 'hidden ',
|
|
range: {
|
|
start,
|
|
end: start
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
title: 'Fix hidden terminals',
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: true,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: changes
|
|
}
|
|
}
|
|
};
|
|
}
|
|
addNewRule(diagnostic, data, document) {
|
|
const offset = document.textDocument.offsetAt(diagnostic.range.start);
|
|
const rootCst = document.parseResult.value.$cstNode;
|
|
if (rootCst) {
|
|
const cstNode = findLeafNodeAtOffset(rootCst, offset);
|
|
const container = getContainerOfType(cstNode === null || cstNode === void 0 ? void 0 : cstNode.astNode, ast.isParserRule);
|
|
if (container && container.$cstNode) {
|
|
return {
|
|
title: `Add new rule '${data.refText}'`,
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: false,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: {
|
|
start: container.$cstNode.range.end,
|
|
end: container.$cstNode.range.end
|
|
},
|
|
newText: '\n\n' + data.refText + ':\n /* TODO implement rule */ {infer ' + data.refText + '};'
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
lookInGlobalScope(diagnostic, data, document) {
|
|
var _a, _b;
|
|
const refInfo = {
|
|
container: {
|
|
$type: data.containerType
|
|
},
|
|
property: data.property,
|
|
reference: {
|
|
$refText: data.refText
|
|
}
|
|
};
|
|
const referenceType = this.reflection.getReferenceType(refInfo);
|
|
const candidates = this.indexManager.allElements(referenceType).filter(e => e.name === data.refText);
|
|
const result = [];
|
|
let shortestPathIndex = -1;
|
|
let shortestPathLength = -1;
|
|
for (const candidate of candidates) {
|
|
if (UriUtils.equals(candidate.documentUri, document.uri)) {
|
|
continue;
|
|
}
|
|
// Find an import path and a position to insert the import
|
|
const importPath = getRelativeImport(document.uri, candidate.documentUri);
|
|
let position;
|
|
let suffix = '';
|
|
const grammar = document.parseResult.value;
|
|
const nextImport = grammar.imports.find(imp => imp.path && importPath < imp.path);
|
|
if (nextImport) {
|
|
// Insert the new import alphabetically
|
|
position = (_a = nextImport.$cstNode) === null || _a === void 0 ? void 0 : _a.range.start;
|
|
}
|
|
else if (grammar.imports.length > 0) {
|
|
// Put the new import after the last import
|
|
const rangeEnd = grammar.imports[grammar.imports.length - 1].$cstNode.range.end;
|
|
if (rangeEnd) {
|
|
position = { line: rangeEnd.line + 1, character: 0 };
|
|
}
|
|
}
|
|
else if (grammar.rules.length > 0) {
|
|
// Put the new import before the first rule
|
|
position = (_b = grammar.rules[0].$cstNode) === null || _b === void 0 ? void 0 : _b.range.start;
|
|
suffix = '\n';
|
|
}
|
|
if (position) {
|
|
if (shortestPathIndex < 0 || importPath.length < shortestPathLength) {
|
|
shortestPathIndex = result.length;
|
|
shortestPathLength = importPath.length;
|
|
}
|
|
// Add an import declaration for the candidate in the global scope
|
|
result.push({
|
|
title: `Add import to '${importPath}'`,
|
|
kind: CodeActionKind.QuickFix,
|
|
diagnostics: [diagnostic],
|
|
isPreferred: false,
|
|
edit: {
|
|
changes: {
|
|
[document.textDocument.uri]: [{
|
|
range: {
|
|
start: position,
|
|
end: position
|
|
},
|
|
newText: `import '${importPath}'\n${suffix}`
|
|
}]
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
// Mark the code action with the shortest import path as preferred
|
|
if (shortestPathIndex >= 0) {
|
|
result[shortestPathIndex].isPreferred = true;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
function getRelativeImport(source, target) {
|
|
const sourceDir = UriUtils.dirname(source);
|
|
let relativePath = UriUtils.relative(sourceDir, target);
|
|
if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
|
|
relativePath = './' + relativePath;
|
|
}
|
|
if (relativePath.endsWith('.langium')) {
|
|
relativePath = relativePath.substring(0, relativePath.length - '.langium'.length);
|
|
}
|
|
return relativePath;
|
|
}
|
|
//# sourceMappingURL=grammar-code-actions.js.map
|