Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8a3f0bc
feat-fix(repl): respect new arg in queries
MaxAtoms Oct 23, 2025
1406e28
feat-fix(repl): parse help command correctly
MaxAtoms Oct 23, 2025
167041d
feat(repl): add line parser for linter
MaxAtoms Oct 23, 2025
edfca59
feat(repl): parse input for each query separately
MaxAtoms Oct 24, 2025
6c3355e
feat(repl): centralize input processing for queries
MaxAtoms Oct 24, 2025
3de1af3
refactor(repl): review comments
MaxAtoms Oct 25, 2025
df03321
feat-fix(analyzer): analyzer should control request changes
MaxAtoms Oct 25, 2025
ce02fab
feat(repl): use output to print error messages
MaxAtoms Oct 27, 2025
d3162ad
refactor(repl): introduce variable
MaxAtoms Oct 27, 2025
775da56
refactor(analyzer): introduce ModifiableFlowrAnalysisProvider
MaxAtoms Oct 27, 2025
24510fb
Merge remote-tracking branch 'origin/main' into linter-query-parser
MaxAtoms Oct 27, 2025
bbe0d9f
feat-fix(repl): set correct field
MaxAtoms Oct 27, 2025
ad3b6df
refactor(analyzer): rename interfaces
MaxAtoms Oct 27, 2025
de1b6e1
feat(repl): linter completions
MaxAtoms Oct 27, 2025
59adc38
refactor(repl): omit empty array creation
MaxAtoms Oct 27, 2025
9ce8476
feat(repl): complete current linter rule only
MaxAtoms Oct 27, 2025
78a0c3a
feat-fix(repl): properly complete file arg
MaxAtoms Oct 27, 2025
4c341c8
Merge remote-tracking branch 'origin/main' into linter-query-completions
MaxAtoms Oct 27, 2025
b1ed564
test(repl): add linter query completion tests
MaxAtoms Oct 28, 2025
9b43af2
refactor(repl): review comments
MaxAtoms Oct 28, 2025
4b7093c
fixup! refactor(repl): review comments
MaxAtoms Oct 28, 2025
dcbc0f2
test(repl): add config query completion tests
MaxAtoms Oct 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions src/cli/repl/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ function replCompleterKeywords() {
}
const defaultHistoryFile = path.join(os.tmpdir(), '.flowrhistory');

/**
* Completion suggestions for a specific REPL command
*/
export interface CommandCompletions {
/** The possible completions for the current argument */
readonly completions: string[];
/**
* The current argument fragment being completed, if any.
* This is relevant if an argument is composed of multiple parts (e.g. comma-separated lists).
*/
readonly argumentPart?: string;
}

/**
* Used by the repl to provide automatic completions for a given (partial) input line
*/
Expand All @@ -45,22 +58,26 @@ export function replCompleter(line: string, config: FlowrConfigOptions): [string
if(splitLine.length > 1 || startingNewArg){
const commandNameColon = replCompleterKeywords().find(k => splitLine[0] === k);
if(commandNameColon) {
const completions: string[] = [];
let completions: string[] = [];
let currentArg = startingNewArg ? '' : splitLine[splitLine.length - 1];

const commandName = commandNameColon.slice(1);
const cmd = getCommand(commandName);
if(cmd?.script === true){
// autocomplete script arguments
const options = scripts[commandName as keyof typeof scripts].options;
completions.push(...getValidOptionsForCompletion(options, splitLine).map(o => `${o} `));
completions = completions.concat(getValidOptionsForCompletion(options, splitLine).map(o => `${o} `));
} else if(commandName.startsWith('query')) {
completions.push(...replQueryCompleter(splitLine, startingNewArg, config));
const { completions: queryCompletions, argumentPart: splitArg } = replQueryCompleter(splitLine, startingNewArg, config);
if(splitArg !== undefined) {
currentArg = splitArg;
}
completions = completions.concat(queryCompletions);
} else {
// autocomplete command arguments (specifically, autocomplete the file:// protocol)
completions.push(fileProtocol);
}

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

function replQueryCompleter(splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions): string[] {
function replQueryCompleter(splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions): CommandCompletions {
const nonEmpty = splitLine.slice(1).map(s => s.trim()).filter(s => s.length > 0);
const queryShorts = Object.keys(SupportedQueries).map(q => `@${q}`).concat(['help']);
let candidates: string[] = [];
if(nonEmpty.length == 0 || (nonEmpty.length == 1 && queryShorts.some(q => q.startsWith(nonEmpty[0]) && nonEmpty[0] !== q && !startingNewArg))) {
candidates = candidates.concat(queryShorts.map(q => `${q} `));
return { completions: queryShorts.map(q => `${q} `) };
} else {
const q = nonEmpty[0].slice(1);
const queryElement = SupportedQueries[q as keyof typeof SupportedQueries] as SupportedQuery;
if(queryElement?.completer) {
candidates = candidates.concat(queryElement.completer(nonEmpty.slice(1), config));
return queryElement.completer(nonEmpty.slice(1), startingNewArg, config);
}
}

return candidates;
return { completions: [] };
}

export function makeDefaultReplReadline(config: FlowrConfigOptions): readline.ReadLineOptions {
Expand Down
15 changes: 8 additions & 7 deletions src/queries/catalog/config-query/config-query-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { jsonReplacer } from '../../../util/json';
import type { DeepPartial } from 'ts-essentials';
import type { ParsedQueryLine, SupportedQuery } from '../../query';
import type { ReplOutput } from '../../../cli/repl/commands/repl-main';
import type { CommandCompletions } from '../../../cli/repl/core';

export interface ConfigQuery extends BaseQueryFormat {
readonly type: 'config';
Expand All @@ -18,16 +19,16 @@ export interface ConfigQueryResult extends BaseQueryResult {
readonly config: FlowrConfigOptions;
}

function configReplCompleter(partialLine: readonly string[], config: FlowrConfigOptions): string[] {
function configReplCompleter(partialLine: readonly string[], _startingNewArg: boolean, config: FlowrConfigOptions): CommandCompletions {
if(partialLine.length === 0) {
// update specific fields
return ['+'];
return { completions: ['+'] };
} else if(partialLine.length === 1 && partialLine[0].startsWith('+')) {
const path = partialLine[0].slice(1).split('.').filter(p => p.length > 0);
const fullPath = path.slice();
const lastPath = partialLine[0].endsWith('.') ? '' : path.pop() ?? '';
if(lastPath.endsWith('=')) {
return [];
return { completions: [] };
}
const subConfig = path.reduce<object | undefined>((obj, key) => (
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);
Expand All @@ -36,15 +37,15 @@ function configReplCompleter(partialLine: readonly string[], config: FlowrConfig
.filter(k => k.startsWith(lastPath) && k !== lastPath)
.map(k => `${partialLine[0].slice(0,1)}${[...path, k].join('.')}`);
if(have.length > 0) {
return have;
return { completions: have };
} else if(lastPath.length > 0) {
return [`${partialLine[0].slice(0,1)}${fullPath.join('.')}.`];
return { completions: [`${partialLine[0].slice(0,1)}${fullPath.join('.')}.`] };
}
}
return [`${partialLine[0].slice(0,1)}${fullPath.join('.')}=`];
return { completions: [`${partialLine[0].slice(0,1)}${fullPath.join('.')}=`] };
}

return [];
return { completions: [] };
}

function configQueryLineParser(output: ReplOutput, line: readonly string[], _config: FlowrConfigOptions): ParsedQueryLine {
Expand Down
41 changes: 38 additions & 3 deletions src/queries/catalog/linter-query/linter-query-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { printAsMs } from '../../../util/text/time';
import { codeInline } from '../../../documentation/doc-util/doc-code';
import type { FlowrConfigOptions } from '../../../config';
import type { ReplOutput } from '../../../cli/repl/commands/repl-main';
import type { CommandCompletions } from '../../../cli/repl/core';
import { fileProtocol } from '../../../r-bridge/retriever';

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

const rulesPrefix = 'rules:';

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

function linterQueryCompleter(line: readonly string[], startingNewArg: boolean, _config: FlowrConfigOptions): CommandCompletions {
const rulesPrefixNotPresent = line.length == 0 || (line.length == 1 && line[0].length < rulesPrefix.length);
const rulesNotFinished = line.length == 1 && line[0].startsWith(rulesPrefix) && !startingNewArg;
const endOfRules = line.length == 1 && startingNewArg || line.length == 2;

if(rulesPrefixNotPresent) {
return { completions: [`${rulesPrefix}`] };
} else if(endOfRules) {
return { completions: [fileProtocol] };
} else if(rulesNotFinished) {
const rulesWithoutPrefix = line[0].slice(rulesPrefix.length);
const usedRules = rulesWithoutPrefix.split(',').map(r => r.trim());
const allRules = Object.keys(LintingRules);
const unusedRules = allRules.filter(r => !usedRules.includes(r));
const lastRule = usedRules[usedRules.length - 1];
const lastRuleIsUnfinished = !allRules.includes(lastRule);

if(lastRuleIsUnfinished) {
// Return all rules that have not been added yet
return { completions: unusedRules, argumentPart: lastRule };
} else if(unusedRules.length > 0) {
// Add a comma, if the current last rule is complete
return { completions: [','], argumentPart: '' };
} else {
// All rules are used, complete with a space
return { completions: [' '], argumentPart: '' };
}
}
return { completions: [] };
}

export const LinterQueryDefinition = {
executor: executeLinterQuery,
asciiSummarizer: (formatter, _analyzer, queryResults, result) => {
Expand All @@ -76,8 +110,9 @@ export const LinterQueryDefinition = {
}
return true;
},
fromLine: linterQueryLineParser,
schema: Joi.object({
completer: linterQueryCompleter,
fromLine: linterQueryLineParser,
schema: Joi.object({
type: Joi.string().valid('linter').required().description('The type of the query.'),
rules: Joi.array().items(
Joi.string().valid(...Object.keys(LintingRules)),
Expand Down
3 changes: 2 additions & 1 deletion src/queries/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
import type { ReadonlyFlowrAnalysisProvider } from '../project/flowr-analyzer';
import { log } from '../util/log';
import type { ReplOutput } from '../cli/repl/commands/repl-main';
import type { CommandCompletions } from '../cli/repl/core';

/**
* These are all queries that can be executed from within flowR
Expand Down Expand Up @@ -102,7 +103,7 @@ export interface ParsedQueryLine {
export interface SupportedQuery<QueryType extends BaseQueryFormat['type'] = BaseQueryFormat['type']> {
executor: QueryExecutor<QueryArgumentsWithType<QueryType>, Promise<BaseQueryResult>>
/** optional completion in, e.g., the repl */
completer?: (splitLine: readonly string[], config: FlowrConfigOptions) => string[]
completer?: (splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions) => CommandCompletions;
/** optional query construction from an, e.g., repl line */
fromLine?: (output: ReplOutput, splitLine: readonly string[], config: FlowrConfigOptions) => ParsedQueryLine
asciiSummarizer: (formatter: OutputFormatter, analyzer: ReadonlyFlowrAnalysisProvider, queryResults: BaseQueryResult, resultStrings: string[], query: readonly Query[]) => AsyncOrSync<boolean>
Expand Down
20 changes: 20 additions & 0 deletions test/functionality/_helper/repl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { FlowrConfigOptions } from '../../../src/config';
import { defaultConfigOptions } from '../../../src/config';
import type { CommandCompletions } from '../../../src/cli/repl/core';
import { expect, test } from 'vitest';

export interface ReplCompletionTestCase {
completer: (splitLine: readonly string[], startingNewArg: boolean, config: FlowrConfigOptions) => CommandCompletions,
label: string,
startingNewArg: boolean,
config?: object,
splitLine: readonly string[],
expectedCompletions: readonly string[]
}

export function assertReplCompletions({ completer, label, startingNewArg, splitLine, config = defaultConfigOptions, expectedCompletions }: ReplCompletionTestCase) {
test(label, () => {
const result = completer(splitLine, startingNewArg, config as FlowrConfigOptions);
expect(result.completions).toEqual(expectedCompletions);
});
}
67 changes: 67 additions & 0 deletions test/functionality/cli/repl/config-query-repl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe } from 'vitest';
import { SupportedQueries } from '../../../../src/queries/query';
import { assertReplCompletions } from '../../_helper/repl';

describe('Config Query REPL Completions', () => {
const completer = SupportedQueries['config'].completer;
assertReplCompletions({ completer,
label: 'empty arguments',
startingNewArg: true,
splitLine: [],
expectedCompletions: ['+']
});
assertReplCompletions({ completer,
label: 'all root nodes',
startingNewArg: false,
config: { aTopNode: 'test', bTopNode: 'test' },
splitLine: ['+'],
expectedCompletions: ['+aTopNode', '+bTopNode'],
});
assertReplCompletions({ completer,
label: 'provides completion for partial root node',
startingNewArg: false,
config: { aTopNode: 'test', bTopNode: 'test' },
splitLine: ['+a'],
expectedCompletions: ['+aTopNode'],
});
assertReplCompletions({ completer,
label: 'adds dot after full root node',
startingNewArg: false,
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
splitLine: ['+bTopNode'],
expectedCompletions: ['+bTopNode.']
});
assertReplCompletions({ completer,
label: 'all second level nodes',
startingNewArg: false,
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
splitLine: ['+bTopNode.'],
expectedCompletions: ['+bTopNode.aSecondNode', '+bTopNode.bSecondNode'],
});
assertReplCompletions({ completer,
label: 'provides completion for partial second level node',
startingNewArg: false,
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
splitLine: ['+bTopNode.b'],
expectedCompletions: ['+bTopNode.bSecondNode'],
});
assertReplCompletions({ completer,
label: 'adds equals sign after full path',
startingNewArg: false,
config: { aTopNode: 'test', bTopNode: { aSecondNode: 'test', bSecondNode: 'test', } },
splitLine: ['+bTopNode.bSecondNode'],
expectedCompletions: ['+bTopNode.bSecondNode='],
});
assertReplCompletions({ completer,
label: 'no completions after equals sign',
startingNewArg: false,
splitLine: ['+someConfigThing='],
expectedCompletions: [],
});
assertReplCompletions({ completer,
label: 'no completions after config update string',
startingNewArg: true,
splitLine: ['+someConfigThing', 'abc'],
expectedCompletions: [],
});
});
76 changes: 76 additions & 0 deletions test/functionality/cli/repl/linter-query-repl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe } from 'vitest';
import { assertReplCompletions } from '../../_helper/repl';
import { SupportedQueries } from '../../../../src/queries/query';
import { fileProtocol } from '../../../../src/r-bridge/retriever';
import { LintingRules } from '../../../../src/linter/linter-rules';

describe('Linter Query REPL Completions', () => {
const completer = SupportedQueries['linter'].completer;
const allRules = Object.keys(LintingRules);
assertReplCompletions({ completer,
label: 'empty arguments',
startingNewArg: true,
splitLine: [''],
expectedCompletions: ['rules:']
});
assertReplCompletions({ completer,
label: 'partial prefix',
startingNewArg: false,
splitLine: ['r'],
expectedCompletions: ['rules:']
});
assertReplCompletions({ completer,
label: 'no rules',
startingNewArg: false,
splitLine: ['rules:'],
expectedCompletions: allRules
});
assertReplCompletions({ completer,
label: 'partial rule',
startingNewArg: false,
splitLine: ['rules:d'],
expectedCompletions: allRules
});
assertReplCompletions({ completer,
label: 'partial unique rule',
startingNewArg: false,
splitLine: ['rules:dead'],
expectedCompletions: allRules
});
assertReplCompletions({ completer,
label: 'multiple rules, one partial',
startingNewArg: false,
splitLine: ['rules:dead-code,file-path-val'],
expectedCompletions: allRules.filter(l => !(l === 'dead-code'))
});
assertReplCompletions({ completer,
label: 'multiple rules, no new one',
startingNewArg: false,
splitLine: ['rules:dead-code,file-path-validity'],
expectedCompletions: [',']
});
assertReplCompletions({ completer,
label: 'multiple rules, starting new one',
startingNewArg: false,
splitLine: ['rules:dead-code,file-path-validity,'],
expectedCompletions: allRules.filter(l => !['dead-code','file-path-validity'].includes(l))
});
assertReplCompletions({ completer,
label: 'all rules used',
startingNewArg: false,
splitLine: [`rules:${allRules.join(',')}`],
expectedCompletions: [' ']
});
assertReplCompletions({ completer,
label: 'rules finished',
startingNewArg: true,
splitLine: ['rules:dead'],
expectedCompletions: [fileProtocol]
});
assertReplCompletions({ completer,
label: 'rules finished',
startingNewArg: true,
splitLine: ['rules:dead'],
expectedCompletions: [fileProtocol]
});
});