@eagleoutice/flowr-dev
Version:
Static Dataflow Analyzer and Program Slicer for the R Programming Language
274 lines • 13.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SYNTACTICALLY_VALID = exports.SyntaxErrorFixPatterns = void 0;
const linter_format_1 = require("../linter-format");
const flowr_search_builder_1 = require("../../search/flowr-search-builder");
const range_1 = require("../../util/range");
const linter_tags_1 = require("../linter-tags");
const DelimiterInsert = new Map([[')', ')'], ['}', '}'], [']', ']'], [']]', ']]'], ['"', '"'], ['\'', '\''], ['`', '`'], [',', ','], [';', ';']]);
const KeywordInsert = new Map([['in', ' in '], ['else', ' else ']]);
const OperatorToken = /^([-+*/^:~!<>=&|@$]+|<-|<<-|->|->>|\|>|%[^%]*%)$/;
const KnownOperators = ['%%', '%/%', '%*%', '%o%', '%x%', '%in%', '%>%', '%<>%', '%+%', '%||%'];
/** Typographic quotes, as a word processor or a PDF leaves behind. */
const SmartQuote = new Map([['\u201C', '"'], ['\u201D', '"'], ['\u201E', '"'], ['\u2018', '\''], ['\u2019', '\'']]);
/** Closing brackets and whitespace only, so nothing there is worth keeping. */
const OnlyClosers = /^[)\]}\s]+$/;
/** How R prefixes printed values, never how a statement begins. */
const ConsoleOutput = /^\s*\[{1,2}\d+\]{1,2}(\s|$)/;
// tree-sitter is 0-based with an exclusive end column (matching flowR), so only the 1-based start shifts
const point = (p) => [p.row + 1, p.column + 1, p.row + 1, p.column];
const span = (n) => [n.startPosition.row + 1, n.startPosition.column + 1, n.endPosition.row + 1, n.endPosition.column];
const isDanglingOperator = (n) => !!n && !n.isNamed && OperatorToken.test(n.type);
/** Read an error region: what it leaves open, and where its operators sit. */
function scanRegion(text) {
const stack = [];
const percentAt = [];
let quote;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote) {
if (c === '\\') {
i++;
}
else if (c === quote) {
quote = undefined;
}
}
else if (c === '"' || c === '\'' || c === '`') {
quote = c;
}
else if (c === '#') {
while (i < text.length && text[i] !== '\n') {
i++;
}
}
else if (c === '%') {
percentAt.push(i);
}
else if (c === '(') {
stack.push(')');
}
else if (c === '[') {
stack.push(text[i + 1] === '[' ? (i++, ']]') : ']');
}
else if (c === '{') {
stack.push('}');
}
else if ((c === ')' || c === '}') && stack[stack.length - 1] === c) {
stack.pop();
}
else if (c === ']') {
if (stack[stack.length - 1] === ']') {
stack.pop();
}
else if (stack[stack.length - 1] === ']]') {
stack[stack.length - 1] = ']';
}
}
}
return { closers: stack.reverse(), percentAt };
}
/** Fuzzy-complete the `%...` operator at `at` to the nearest known one (else just close it). */
function operatorCompletion(node, at) {
// R allows anything between the percents; matching letters alone read `%>` as `%` and completed it to `%%`
const fragment = /^%[^%\s()[\]{},]*/.exec(node.text.slice(at))?.[0] ?? '%';
const full = KnownOperators.find(op => op !== fragment && op.startsWith(fragment)) ?? fragment + '%';
const column = node.startPosition.column + at;
return { range: [node.startPosition.row + 1, column + 1, node.startPosition.row + 1, column + fragment.length], full };
}
const isMissingExpression = (f) => f.kind === 'missing' && !DelimiterInsert.has(f.node.type) && !KeywordInsert.has(f.node.type);
/** The built-in auto-fix patterns; append a {@link SyntaxErrorFixPattern} to add repairs. */
exports.SyntaxErrorFixPatterns = [
// first, because it judges the whole line: `[1] 1 2 3` errors on both `[` and `]`, and repairing those as
// brackets balances output text into the program instead of dropping it
{
name: 'comment-out-console-output',
description: 'Comment out a line of pasted R console output, which is missing its `#`.',
direction: 'comment',
appliesTo: f => f.kind === 'error' && ConsoleOutput.test(f.line),
quickFix: (f, file) => ({
type: 'replace',
loc: range_1.SourceLocation.from(point({ row: f.node.startPosition.row, column: 0 }), file),
description: 'Comment out the pasted console output',
replacement: '# '
})
},
{
name: 'insert-missing-token',
description: 'Insert a delimiter, quote, or keyword the parser expected but did not find.',
direction: 'add',
appliesTo: f => f.kind === 'missing' && (DelimiterInsert.has(f.node.type) || KeywordInsert.has(f.node.type)),
quickFix: (f, file) => ({
type: 'replace',
loc: range_1.SourceLocation.from(point(f.node.startPosition), file),
description: `Insert missing \`${f.node.type}\``,
replacement: DelimiterInsert.get(f.node.type) ?? KeywordInsert.get(f.node.type)
})
},
{
name: 'remove-dangling-operator',
description: 'Drop a dangling operator whose operand the parser found missing.',
direction: 'remove',
appliesTo: f => isMissingExpression(f) && isDanglingOperator(f.node.previousSibling),
quickFix: (f, file) => ({ type: 'remove', loc: range_1.SourceLocation.from(span(f.node.previousSibling), file), description: `Remove the dangling \`${f.node.previousSibling.type}\`` })
},
{
name: 'insert-operand-placeholder',
description: 'Insert a `NULL` placeholder for a missing operand or body.',
direction: 'add',
appliesTo: isMissingExpression,
quickFix: (f, file) => ({ type: 'replace', loc: range_1.SourceLocation.from(point(f.node.startPosition), file), description: 'Insert placeholder `NULL`', replacement: ' NULL' })
},
{
name: 'balance-brackets',
description: 'Close an unbalanced region by appending the brackets that were left open.',
direction: 'add',
appliesTo: f => f.kind === 'error' && f.scan.closers.length > 0,
quickFix: (f, file) => {
const closers = f.scan.closers.join('');
return { type: 'replace', loc: range_1.SourceLocation.from(point(f.node.endPosition), file), description: `Add missing closing \`${closers}\``, replacement: closers };
}
},
{
name: 'complete-operator',
description: 'Complete an unfinished `%...%` operator to the nearest known one.',
direction: 'add',
// single-line only: the completion derives its column from the offset within the region
appliesTo: f => f.kind === 'error' && !f.node.text.includes('\n') && f.scan.percentAt.length % 2 === 1,
quickFix: (f, file) => {
const { range, full } = operatorCompletion(f.node, f.scan.percentAt.at(-1));
return { type: 'replace', loc: range_1.SourceLocation.from(range, file), description: `Complete operator to \`${full}\``, replacement: full };
}
},
{
name: 'replace-smart-quote',
description: 'Turn a typographic quote back into the straight quote R can read.',
direction: 'add',
appliesTo: f => f.kind === 'error' && SmartQuote.has(f.node.text),
quickFix: (f, file) => ({
type: 'replace',
loc: range_1.SourceLocation.from(span(f.node), file),
description: 'Replace the typographic quote with a straight one',
replacement: SmartQuote.get(f.node.text)
})
},
{
name: 'remove-repl-prompt',
description: 'Drop a `>` prompt copied in front of a line taken from the R console.',
direction: 'remove',
// must open the line: `a > b` parses, and a leading `>=` is a different token
appliesTo: f => f.kind === 'error' && f.node.text === '>'
&& f.line.slice(0, f.node.startPosition.column).trim() === '',
quickFix: (f, file) => ({ type: 'remove', loc: range_1.SourceLocation.from(span(f.node), file), description: 'Remove the copied `>` prompt' })
},
{
name: 'remove-stray-closer',
description: 'Drop closing brackets that close nothing, as a partial copy leaves behind.',
direction: 'remove',
// the `]` of `[1]` is a closer too, but there the line goes, not the bracket
appliesTo: f => f.kind === 'error' && OnlyClosers.test(f.node.text) && !ConsoleOutput.test(f.line),
quickFix: (f, file) => ({ type: 'remove', loc: range_1.SourceLocation.from(span(f.node), file), description: `Remove the stray \`${f.node.text.replace(/\s+/g, '')}\`` })
},
{
name: 'comment-out',
description: 'Comment out an un-parseable region so the rest of the file still parses.',
direction: 'comment',
appliesTo: f => f.kind === 'error' && !f.node.text.includes('\n'),
quickFix: (f, file) => ({ type: 'replace', loc: range_1.SourceLocation.from(span(f.node), file), description: 'Comment out the offending code', replacement: '# ' + f.node.text })
}
];
/** Collect the outermost `missing`/`error` nodes, pruning subtrees the parser reports as clean. */
function finding(kind, node, lines) {
let scan;
return {
kind, node,
line: lines[node.startPosition.row] ?? '',
// on demand and once: a `missing` node has no text, and two patterns want the same scan
get scan() {
return scan ??= scanRegion(node.text);
}
};
}
function collectFindings(node, lines, out = []) {
if (node.isMissing) {
out.push(finding('missing', node, lines));
}
else if (node.isError) {
out.push(finding('error', node, lines));
}
else if (node.hasError) {
for (const child of node.children) {
collectFindings(child, lines, out);
}
}
return out;
}
function describe(finding) {
if (finding.kind === 'missing') {
return `Missing \`${finding.node.type}\``;
}
const text = finding.node.text.replace(/\s+/g, ' ').trim();
return `Unexpected \`${text.length > 30 ? text.slice(0, 27) + '...' : text}\``;
}
exports.SYNTACTICALLY_VALID = {
// reads the parse tree, not the normalized AST, so `Q.all()` would enumerate every node for nothing
createSearch: () => flowr_search_builder_1.Q.from([]),
processSearchResult: async (_elements, config, data) => {
const parser = data.parserInformation().name;
const results = [];
// only tree-sitter recovers from syntax errors; a hard parser aborts before the linter ever runs
if (parser === 'tree-sitter') {
const patterns = exports.SyntaxErrorFixPatterns.filter(p => !config.disabledFixes.includes(p.name));
for (const file of (await data.parse()).files) {
const root = file.parsed.rootNode;
if (!root.hasError) {
continue;
}
// the root spans the file verbatim, so its rows index these lines
const lines = root.text.split('\n');
for (const finding of collectFindings(root, lines)) {
// a single fix per error, favouring the preferred direction (alternatives are never emitted together)
let fix;
let best = 2;
for (const p of patterns) {
const rank = p.direction === config.preferFix ? 0 : 1;
if (rank < best && p.appliesTo(finding)) {
fix = p.quickFix(finding, file.filePath);
best = rank;
if (best === 0) {
break;
}
}
}
results.push({
certainty: linter_format_1.LintingResultCertainty.Certain,
kind: finding.kind,
involvedId: undefined,
loc: range_1.SourceLocation.from(span(finding.node), file.filePath),
message: describe(finding),
quickFix: fix ? [fix] : undefined
});
}
}
}
return { results, '.meta': { parser, errors: results.length, fixable: results.filter(r => r.quickFix !== undefined).length } };
},
prettyPrint: {
[linter_format_1.LintingPrettyPrintContext.Query]: (result) => `${result.message} at ${range_1.SourceLocation.format(result.loc)}`,
[linter_format_1.LintingPrettyPrintContext.Full]: (result) => `Syntax error (${result.kind}): ${result.message} at ${range_1.SourceLocation.format(result.loc)}`
},
info: {
name: 'Syntactically Valid',
description: 'Checks whether the code is free of syntax errors, using the configured (error-tolerant) parser, and offers extensible quick-fixes to repair them.',
tags: [linter_tags_1.LintingRuleTag.Bug, linter_tags_1.LintingRuleTag.Robustness, linter_tags_1.LintingRuleTag.QuickFix],
// tree-sitter reports real syntax errors precisely, but a strict parse may collapse a region into fewer findings
certainty: linter_format_1.LintingRuleCertainty.BestEffort,
// only meaningful under tree-sitter, so opt-in rather than part of the default set
activeByDefault: false,
defaultConfig: {
disabledFixes: [],
preferFix: 'remove'
}
}
};
//# sourceMappingURL=syntactically-valid.js.map