Skip to content

Commit 4ed7cb1

Browse files
authored
[REPL] Linter query completions (#1993)
* feat-fix(repl): respect new arg in queries Previously, all queries would be returned when trying to complete ':query @dataflow '. * feat-fix(repl): parse help command correctly * feat(repl): add line parser for linter * feat(repl): parse input for each query separately * feat(repl): centralize input processing for queries * refactor(repl): review comments * feat-fix(analyzer): analyzer should control request changes * feat(repl): use output to print error messages * refactor(repl): introduce variable * refactor(analyzer): introduce ModifiableFlowrAnalysisProvider * feat-fix(repl): set correct field * refactor(analyzer): rename interfaces * feat(repl): linter completions * refactor(repl): omit empty array creation * feat(repl): complete current linter rule only * feat-fix(repl): properly complete file arg * test(repl): add linter query completion tests * refactor(repl): review comments * fixup! refactor(repl): review comments * test(repl): add config query completion tests
1 parent 13b149a commit 4ed7cb1

File tree

7 files changed

+236
-20
lines changed

7 files changed

+236
-20
lines changed

src/cli/repl/core.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ function replCompleterKeywords() {
3333
}
3434
const defaultHistoryFile = path.join(os.tmpdir(), '.flowrhistory');
3535

36+
/**
37+
* Completion suggestions for a specific REPL command
38+
*/
39+
export interface CommandCompletions {
40+
/** The possible completions for the current argument */
41+
readonly completions: string[];
42+
/**
43+
* The current argument fragment being completed, if any.
44+
* This is relevant if an argument is composed of multiple parts (e.g. comma-separated lists).
45+
*/
46+
readonly argumentPart?: string;
47+
}
48+
3649
/**
3750
* Used by the repl to provide automatic completions for a given (partial) input line
3851
*/
@@ -45,22 +58,26 @@ export function replCompleter(line: string, config: FlowrConfigOptions): [string
4558
if(splitLine.length > 1 || startingNewArg){
4659
const commandNameColon = replCompleterKeywords().find(k => splitLine[0] === k);
4760
if(commandNameColon) {
48-
const completions: string[] = [];
61+
let completions: string[] = [];
62+
let currentArg = startingNewArg ? '' : splitLine[splitLine.length - 1];
4963

5064
const commandName = commandNameColon.slice(1);
5165
const cmd = getCommand(commandName);
5266
if(cmd?.script === true){
5367
// autocomplete script arguments
5468
const options = scripts[commandName as keyof typeof scripts].options;
55-
completions.push(...getValidOptionsForCompletion(options, splitLine).map(o => `${o} `));
69+
completions = completions.concat(getValidOptionsForCompletion(options, splitLine).map(o => `${o} `));
5670
} else if(commandName.startsWith('query')) {
57-
completions.push(...replQueryCompleter(splitLine, startingNewArg, config));
71+
const { completions: queryCompletions, argumentPart: splitArg } = replQueryCompleter(splitLine, startingNewArg, config);
72+
if(splitArg !== undefined) {
73+
currentArg = splitArg;
74+
}
75+
completions = completions.concat(queryCompletions);
5876
} else {
5977
// autocomplete command arguments (specifically, autocomplete the file:// protocol)
6078
completions.push(fileProtocol);
6179
}
6280

63-
const currentArg = startingNewArg ? '' : splitLine[splitLine.length - 1];
6481
return [completions.filter(a => a.startsWith(currentArg)), currentArg];
6582
}
6683
}
@@ -69,21 +86,20 @@ export function replCompleter(line: string, config: FlowrConfigOptions): [string
6986
return [replCompleterKeywords().filter(k => k.startsWith(line)).map(k => `${k} `), line];
7087
}
7188

72-
function replQueryCompleter(splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions): string[] {
89+
function replQueryCompleter(splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions): CommandCompletions {
7390
const nonEmpty = splitLine.slice(1).map(s => s.trim()).filter(s => s.length > 0);
7491
const queryShorts = Object.keys(SupportedQueries).map(q => `@${q}`).concat(['help']);
75-
let candidates: string[] = [];
7692
if(nonEmpty.length == 0 || (nonEmpty.length == 1 && queryShorts.some(q => q.startsWith(nonEmpty[0]) && nonEmpty[0] !== q && !startingNewArg))) {
77-
candidates = candidates.concat(queryShorts.map(q => `${q} `));
93+
return { completions: queryShorts.map(q => `${q} `) };
7894
} else {
7995
const q = nonEmpty[0].slice(1);
8096
const queryElement = SupportedQueries[q as keyof typeof SupportedQueries] as SupportedQuery;
8197
if(queryElement?.completer) {
82-
candidates = candidates.concat(queryElement.completer(nonEmpty.slice(1), config));
98+
return queryElement.completer(nonEmpty.slice(1), startingNewArg, config);
8399
}
84100
}
85101

86-
return candidates;
102+
return { completions: [] };
87103
}
88104

89105
export function makeDefaultReplReadline(config: FlowrConfigOptions): readline.ReadLineOptions {

src/queries/catalog/config-query/config-query-format.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { jsonReplacer } from '../../../util/json';
88
import type { DeepPartial } from 'ts-essentials';
99
import type { ParsedQueryLine, SupportedQuery } from '../../query';
1010
import type { ReplOutput } from '../../../cli/repl/commands/repl-main';
11+
import type { CommandCompletions } from '../../../cli/repl/core';
1112

1213
export interface ConfigQuery extends BaseQueryFormat {
1314
readonly type: 'config';
@@ -18,16 +19,16 @@ export interface ConfigQueryResult extends BaseQueryResult {
1819
readonly config: FlowrConfigOptions;
1920
}
2021

21-
function configReplCompleter(partialLine: readonly string[], config: FlowrConfigOptions): string[] {
22+
function configReplCompleter(partialLine: readonly string[], _startingNewArg: boolean, config: FlowrConfigOptions): CommandCompletions {
2223
if(partialLine.length === 0) {
2324
// update specific fields
24-
return ['+'];
25+
return { completions: ['+'] };
2526
} else if(partialLine.length === 1 && partialLine[0].startsWith('+')) {
2627
const path = partialLine[0].slice(1).split('.').filter(p => p.length > 0);
2728
const fullPath = path.slice();
2829
const lastPath = partialLine[0].endsWith('.') ? '' : path.pop() ?? '';
2930
if(lastPath.endsWith('=')) {
30-
return [];
31+
return { completions: [] };
3132
}
3233
const subConfig = path.reduce<object | undefined>((obj, key) => (
3334
obj && (obj as Record<string, unknown>)[key] !== undefined && typeof (obj as Record<string, unknown>)[key] === 'object') ? (obj as Record<string, unknown>)[key] as object : obj, config);
@@ -36,15 +37,15 @@ function configReplCompleter(partialLine: readonly string[], config: FlowrConfig
3637
.filter(k => k.startsWith(lastPath) && k !== lastPath)
3738
.map(k => `${partialLine[0].slice(0,1)}${[...path, k].join('.')}`);
3839
if(have.length > 0) {
39-
return have;
40+
return { completions: have };
4041
} else if(lastPath.length > 0) {
41-
return [`${partialLine[0].slice(0,1)}${fullPath.join('.')}.`];
42+
return { completions: [`${partialLine[0].slice(0,1)}${fullPath.join('.')}.`] };
4243
}
4344
}
44-
return [`${partialLine[0].slice(0,1)}${fullPath.join('.')}=`];
45+
return { completions: [`${partialLine[0].slice(0,1)}${fullPath.join('.')}=`] };
4546
}
4647

47-
return [];
48+
return { completions: [] };
4849
}
4950

5051
function configQueryLineParser(output: ReplOutput, line: readonly string[], _config: FlowrConfigOptions): ParsedQueryLine {

src/queries/catalog/linter-query/linter-query-format.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { printAsMs } from '../../../util/text/time';
1717
import { codeInline } from '../../../documentation/doc-util/doc-code';
1818
import type { FlowrConfigOptions } from '../../../config';
1919
import type { ReplOutput } from '../../../cli/repl/commands/repl-main';
20+
import type { CommandCompletions } from '../../../cli/repl/core';
21+
import { fileProtocol } from '../../../r-bridge/retriever';
2022

2123
export interface LinterQuery extends BaseQueryFormat {
2224
readonly type: 'linter';
@@ -47,8 +49,9 @@ function rulesFromInput(output: ReplOutput, rulesPart: readonly string[]): {vali
4749
}, { valid: [] as (LintingRuleNames | ConfiguredLintingRule)[], invalid: [] as string[] });
4850
}
4951

52+
const rulesPrefix = 'rules:';
53+
5054
function linterQueryLineParser(output: ReplOutput, line: readonly string[], _config: FlowrConfigOptions): ParsedQueryLine {
51-
const rulesPrefix = 'rules:';
5255
let rules: (LintingRuleNames | ConfiguredLintingRule)[] | undefined = undefined;
5356
let input: string | undefined = undefined;
5457
if(line.length > 0 && line[0].startsWith(rulesPrefix)) {
@@ -66,6 +69,37 @@ function linterQueryLineParser(output: ReplOutput, line: readonly string[], _con
6669
return { query: [{ type: 'linter', rules: rules }], rCode: input } ;
6770
}
6871

72+
function linterQueryCompleter(line: readonly string[], startingNewArg: boolean, _config: FlowrConfigOptions): CommandCompletions {
73+
const rulesPrefixNotPresent = line.length == 0 || (line.length == 1 && line[0].length < rulesPrefix.length);
74+
const rulesNotFinished = line.length == 1 && line[0].startsWith(rulesPrefix) && !startingNewArg;
75+
const endOfRules = line.length == 1 && startingNewArg || line.length == 2;
76+
77+
if(rulesPrefixNotPresent) {
78+
return { completions: [`${rulesPrefix}`] };
79+
} else if(endOfRules) {
80+
return { completions: [fileProtocol] };
81+
} else if(rulesNotFinished) {
82+
const rulesWithoutPrefix = line[0].slice(rulesPrefix.length);
83+
const usedRules = rulesWithoutPrefix.split(',').map(r => r.trim());
84+
const allRules = Object.keys(LintingRules);
85+
const unusedRules = allRules.filter(r => !usedRules.includes(r));
86+
const lastRule = usedRules[usedRules.length - 1];
87+
const lastRuleIsUnfinished = !allRules.includes(lastRule);
88+
89+
if(lastRuleIsUnfinished) {
90+
// Return all rules that have not been added yet
91+
return { completions: unusedRules, argumentPart: lastRule };
92+
} else if(unusedRules.length > 0) {
93+
// Add a comma, if the current last rule is complete
94+
return { completions: [','], argumentPart: '' };
95+
} else {
96+
// All rules are used, complete with a space
97+
return { completions: [' '], argumentPart: '' };
98+
}
99+
}
100+
return { completions: [] };
101+
}
102+
69103
export const LinterQueryDefinition = {
70104
executor: executeLinterQuery,
71105
asciiSummarizer: (formatter, _analyzer, queryResults, result) => {
@@ -76,8 +110,9 @@ export const LinterQueryDefinition = {
76110
}
77111
return true;
78112
},
79-
fromLine: linterQueryLineParser,
80-
schema: Joi.object({
113+
completer: linterQueryCompleter,
114+
fromLine: linterQueryLineParser,
115+
schema: Joi.object({
81116
type: Joi.string().valid('linter').required().description('The type of the query.'),
82117
rules: Joi.array().items(
83118
Joi.string().valid(...Object.keys(LintingRules)),

src/queries/query.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
import type { ReadonlyFlowrAnalysisProvider } from '../project/flowr-analyzer';
5454
import { log } from '../util/log';
5555
import type { ReplOutput } from '../cli/repl/commands/repl-main';
56+
import type { CommandCompletions } from '../cli/repl/core';
5657

5758
/**
5859
* These are all queries that can be executed from within flowR
@@ -102,7 +103,7 @@ export interface ParsedQueryLine {
102103
export interface SupportedQuery<QueryType extends BaseQueryFormat['type'] = BaseQueryFormat['type']> {
103104
executor: QueryExecutor<QueryArgumentsWithType<QueryType>, Promise<BaseQueryResult>>
104105
/** optional completion in, e.g., the repl */
105-
completer?: (splitLine: readonly string[], config: FlowrConfigOptions) => string[]
106+
completer?: (splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions) => CommandCompletions;
106107
/** optional query construction from an, e.g., repl line */
107108
fromLine?: (output: ReplOutput, splitLine: readonly string[], config: FlowrConfigOptions) => ParsedQueryLine
108109
asciiSummarizer: (formatter: OutputFormatter, analyzer: ReadonlyFlowrAnalysisProvider, queryResults: BaseQueryResult, resultStrings: string[], query: readonly Query[]) => AsyncOrSync<boolean>

test/functionality/_helper/repl.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { FlowrConfigOptions } from '../../../src/config';
2+
import { defaultConfigOptions } from '../../../src/config';
3+
import type { CommandCompletions } from '../../../src/cli/repl/core';
4+
import { expect, test } from 'vitest';
5+
6+
export interface ReplCompletionTestCase {
7+
completer: (splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions) => CommandCompletions,
8+
label: string,
9+
startingNewArg: boolean,
10+
config?: object,
11+
splitLine: readonly string[],
12+
expectedCompletions: readonly string[]
13+
}
14+
15+
export function assertReplCompletions({ completer, label, startingNewArg, splitLine, config = defaultConfigOptions, expectedCompletions }: ReplCompletionTestCase) {
16+
test(label, () => {
17+
const result = completer(splitLine, startingNewArg, config as FlowrConfigOptions);
18+
expect(result.completions).toEqual(expectedCompletions);
19+
});
20+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe } from 'vitest';
2+
import { SupportedQueries } from '../../../../src/queries/query';
3+
import { assertReplCompletions } from '../../_helper/repl';
4+
5+
describe('Config Query REPL Completions', () => {
6+
const completer = SupportedQueries['config'].completer;
7+
assertReplCompletions({ completer,
8+
label: 'empty arguments',
9+
startingNewArg: true,
10+
splitLine: [],
11+
expectedCompletions: ['+']
12+
});
13+
assertReplCompletions({ completer,
14+
label: 'all root nodes',
15+
startingNewArg: false,
16+
config: { aTopNode: 'test', bTopNode: 'test' },
17+
splitLine: ['+'],
18+
expectedCompletions: ['+aTopNode', '+bTopNode'],
19+
});
20+
assertReplCompletions({ completer,
21+
label: 'provides completion for partial root node',
22+
startingNewArg: false,
23+
config: { aTopNode: 'test', bTopNode: 'test' },
24+
splitLine: ['+a'],
25+
expectedCompletions: ['+aTopNode'],
26+
});
27+
assertReplCompletions({ completer,
28+
label: 'adds dot after full root node',
29+
startingNewArg: false,
30+
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
31+
splitLine: ['+bTopNode'],
32+
expectedCompletions: ['+bTopNode.']
33+
});
34+
assertReplCompletions({ completer,
35+
label: 'all second level nodes',
36+
startingNewArg: false,
37+
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
38+
splitLine: ['+bTopNode.'],
39+
expectedCompletions: ['+bTopNode.aSecondNode', '+bTopNode.bSecondNode'],
40+
});
41+
assertReplCompletions({ completer,
42+
label: 'provides completion for partial second level node',
43+
startingNewArg: false,
44+
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
45+
splitLine: ['+bTopNode.b'],
46+
expectedCompletions: ['+bTopNode.bSecondNode'],
47+
});
48+
assertReplCompletions({ completer,
49+
label: 'adds equals sign after full path',
50+
startingNewArg: false,
51+
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
52+
splitLine: ['+bTopNode.bSecondNode'],
53+
expectedCompletions: ['+bTopNode.bSecondNode='],
54+
});
55+
assertReplCompletions({ completer,
56+
label: 'no completions after equals sign',
57+
startingNewArg: false,
58+
splitLine: ['+someConfigThing='],
59+
expectedCompletions: [],
60+
});
61+
assertReplCompletions({ completer,
62+
label: 'no completions after config update string',
63+
startingNewArg: true,
64+
splitLine: ['+someConfigThing', 'abc'],
65+
expectedCompletions: [],
66+
});
67+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe } from 'vitest';
2+
import { assertReplCompletions } from '../../_helper/repl';
3+
import { SupportedQueries } from '../../../../src/queries/query';
4+
import { fileProtocol } from '../../../../src/r-bridge/retriever';
5+
import { LintingRules } from '../../../../src/linter/linter-rules';
6+
7+
describe('Linter Query REPL Completions', () => {
8+
const completer = SupportedQueries['linter'].completer;
9+
const allRules = Object.keys(LintingRules);
10+
assertReplCompletions({ completer,
11+
label: 'empty arguments',
12+
startingNewArg: true,
13+
splitLine: [''],
14+
expectedCompletions: ['rules:']
15+
});
16+
assertReplCompletions({ completer,
17+
label: 'partial prefix',
18+
startingNewArg: false,
19+
splitLine: ['r'],
20+
expectedCompletions: ['rules:']
21+
});
22+
assertReplCompletions({ completer,
23+
label: 'no rules',
24+
startingNewArg: false,
25+
splitLine: ['rules:'],
26+
expectedCompletions: allRules
27+
});
28+
assertReplCompletions({ completer,
29+
label: 'partial rule',
30+
startingNewArg: false,
31+
splitLine: ['rules:d'],
32+
expectedCompletions: allRules
33+
});
34+
assertReplCompletions({ completer,
35+
label: 'partial unique rule',
36+
startingNewArg: false,
37+
splitLine: ['rules:dead'],
38+
expectedCompletions: allRules
39+
});
40+
assertReplCompletions({ completer,
41+
label: 'multiple rules, one partial',
42+
startingNewArg: false,
43+
splitLine: ['rules:dead-code,file-path-val'],
44+
expectedCompletions: allRules.filter(l => !(l === 'dead-code'))
45+
});
46+
assertReplCompletions({ completer,
47+
label: 'multiple rules, no new one',
48+
startingNewArg: false,
49+
splitLine: ['rules:dead-code,file-path-validity'],
50+
expectedCompletions: [',']
51+
});
52+
assertReplCompletions({ completer,
53+
label: 'multiple rules, starting new one',
54+
startingNewArg: false,
55+
splitLine: ['rules:dead-code,file-path-validity,'],
56+
expectedCompletions: allRules.filter(l => !['dead-code','file-path-validity'].includes(l))
57+
});
58+
assertReplCompletions({ completer,
59+
label: 'all rules used',
60+
startingNewArg: false,
61+
splitLine: [`rules:${allRules.join(',')}`],
62+
expectedCompletions: [' ']
63+
});
64+
assertReplCompletions({ completer,
65+
label: 'rules finished',
66+
startingNewArg: true,
67+
splitLine: ['rules:dead'],
68+
expectedCompletions: [fileProtocol]
69+
});
70+
assertReplCompletions({ completer,
71+
label: 'rules finished',
72+
startingNewArg: true,
73+
splitLine: ['rules:dead'],
74+
expectedCompletions: [fileProtocol]
75+
});
76+
});

0 commit comments

Comments
 (0)