diff --git a/src/cli/repl/core.ts b/src/cli/repl/core.ts index 4b55af6a719..e868c2f8de3 100644 --- a/src/cli/repl/core.ts +++ b/src/cli/repl/core.ts @@ -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 */ @@ -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]; } } @@ -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 { diff --git a/src/queries/catalog/config-query/config-query-format.ts b/src/queries/catalog/config-query/config-query-format.ts index 99009c36b31..142655b4ec6 100644 --- a/src/queries/catalog/config-query/config-query-format.ts +++ b/src/queries/catalog/config-query/config-query-format.ts @@ -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'; @@ -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((obj, key) => ( obj && (obj as Record)[key] !== undefined && typeof (obj as Record)[key] === 'object') ? (obj as Record)[key] as object : obj, config); @@ -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 { diff --git a/src/queries/catalog/linter-query/linter-query-format.ts b/src/queries/catalog/linter-query/linter-query-format.ts index b8181c7edc0..78c03319249 100644 --- a/src/queries/catalog/linter-query/linter-query-format.ts +++ b/src/queries/catalog/linter-query/linter-query-format.ts @@ -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'; @@ -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)) { @@ -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) => { @@ -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)), diff --git a/src/queries/query.ts b/src/queries/query.ts index 4f732bb7a0b..02e36b3d4ec 100644 --- a/src/queries/query.ts +++ b/src/queries/query.ts @@ -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 @@ -102,7 +103,7 @@ export interface ParsedQueryLine { export interface SupportedQuery { executor: QueryExecutor, Promise> /** 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 diff --git a/test/functionality/_helper/repl.ts b/test/functionality/_helper/repl.ts new file mode 100644 index 00000000000..f0554537853 --- /dev/null +++ b/test/functionality/_helper/repl.ts @@ -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); + }); +} diff --git a/test/functionality/cli/repl/config-query-repl.test.ts b/test/functionality/cli/repl/config-query-repl.test.ts new file mode 100644 index 00000000000..f15924e2c65 --- /dev/null +++ b/test/functionality/cli/repl/config-query-repl.test.ts @@ -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: [], + }); +}); diff --git a/test/functionality/cli/repl/linter-query-repl.test.ts b/test/functionality/cli/repl/linter-query-repl.test.ts new file mode 100644 index 00000000000..14c7917c625 --- /dev/null +++ b/test/functionality/cli/repl/linter-query-repl.test.ts @@ -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] + }); +});