Skip to content

Commit d8957b7

Browse files
committed
feat: add -input= option to set initial prompt value
Add support for the -input= option to pre-fill the picker's prompt with an initial query. This is useful for starting searches with a predefined term. Usage: :Fall -input="search term" file The -input= option must appear before the source name. Options placed after the source name are treated as source arguments. Changes: - Add parseArgs() and extractOption() utilities in util/args.ts - Update picker:command to extract and process -input= option - Use <q-args> instead of <f-args> to support quoted arguments - Add comprehensive tests (20 test cases) - Update documentation with usage examples and constraints
1 parent abf9c78 commit d8957b7

File tree

5 files changed

+381
-8
lines changed

5 files changed

+381
-8
lines changed

denops/fall/main/picker.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
loadUserCustom,
1313
} from "../custom.ts";
1414
import { isOptions, isPickerParams, isStringArray } from "../util/predicate.ts";
15+
import { extractOption, parseArgs } from "../util/args.ts";
1516
import { action as buildActionSource } from "../extension/source/action.ts";
1617
import { Picker, type PickerContext } from "../picker.ts";
1718
import type { SubmatchContext } from "./submatch.ts";
@@ -34,6 +35,32 @@ const SESSION_EXCLUDE_SOURCES = [
3435
"@session",
3536
];
3637

38+
/**
39+
* Create initial picker context with the specified query.
40+
*
41+
* All fields except query are initialized to their default values:
42+
* - Empty selection, collections, and filtered items
43+
* - Cursor and offset at 0
44+
* - All component indices at 0 (except previewerIndex which is undefined)
45+
*
46+
* @param query - Initial query string for the picker prompt
47+
* @returns PickerContext with default values
48+
*/
49+
function createInitialContext(query: string): PickerContext<Detail> {
50+
return {
51+
query,
52+
selection: new Set(),
53+
collectedItems: [],
54+
filteredItems: [],
55+
cursor: 0,
56+
offset: 0,
57+
matcherIndex: 0,
58+
sorterIndex: 0,
59+
rendererIndex: 0,
60+
previewerIndex: undefined,
61+
};
62+
}
63+
3764
export const main: Entrypoint = (denops) => {
3865
denops.dispatcher = {
3966
...denops.dispatcher,
@@ -43,12 +70,36 @@ export const main: Entrypoint = (denops) => {
4370
assert(options, isOptions);
4471
return startPicker(denops, args, itemPickerParams, options);
4572
},
46-
"picker:command": withHandleError(denops, async (args) => {
73+
"picker:command": withHandleError(denops, async (cmdline) => {
4774
await loadUserCustom(denops);
48-
// Split the command arguments
49-
const [name, ...sourceArgs] = ensure(args, isStringArray);
5075

51-
// Load user custom
76+
// Parse command line arguments
77+
// cmdline is string from denops#request('fall', 'picker:command', [a:args])
78+
assert(cmdline, is.String);
79+
const allArgs = parseArgs(cmdline);
80+
81+
// Find the first non-option argument (source name)
82+
const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-"));
83+
if (sourceIndex === -1) {
84+
throw new ExpectedError(
85+
`Picker name is required. Available item pickers are: ${
86+
listPickerNames().join(", ")
87+
}`,
88+
);
89+
}
90+
91+
// Extract -input= option only from arguments before the source name
92+
const beforeSourceArgs = allArgs.slice(0, sourceIndex);
93+
const afterSourceArgs = allArgs.slice(sourceIndex);
94+
const [inputValues] = extractOption(beforeSourceArgs, "-input=");
95+
const initialQuery = inputValues.at(-1);
96+
// Note: Currently only -input= is supported. Other options before
97+
// the source name are silently ignored for future extensibility.
98+
99+
// Get source name and its arguments
100+
const [name, ...sourceArgs] = afterSourceArgs;
101+
102+
// Load picker params
52103
const itemPickerParams = getPickerParams(name);
53104
if (!itemPickerParams) {
54105
throw new ExpectedError(
@@ -57,11 +108,17 @@ export const main: Entrypoint = (denops) => {
57108
}`,
58109
);
59110
}
111+
112+
// Create context with initial query if specified
113+
const context = initialQuery !== undefined
114+
? createInitialContext(initialQuery)
115+
: undefined;
116+
60117
await startPicker(
61118
denops,
62119
sourceArgs,
63120
itemPickerParams,
64-
{ signal: denops.interrupted },
121+
{ signal: denops.interrupted, context },
65122
);
66123
}),
67124
"picker:command:complete": withHandleError(

denops/fall/util/args.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Utility functions for parsing and processing command-line arguments.
3+
* @module
4+
*/
5+
6+
/**
7+
* Parse command line arguments respecting quotes and escapes.
8+
*
9+
* This function properly handles:
10+
* - Double quotes (`"`)
11+
* - Single quotes (`'`)
12+
* - Escape sequences (`\`)
13+
* - Nested quotes of different types
14+
*
15+
* Note on edge cases:
16+
* - Unclosed quotes: treated as part of the argument value
17+
* - Trailing backslash: ignored (escape with no following character)
18+
* - Empty string or only spaces: returns empty array
19+
*
20+
* @param cmdline - The command line string to parse
21+
* @returns Array of parsed arguments
22+
*
23+
* @example
24+
* ```ts
25+
* parseArgs('file -input="Hello world"')
26+
* // => ['file', '-input=Hello world']
27+
*
28+
* parseArgs("file -input='test'")
29+
* // => ['file', '-input=test']
30+
*
31+
* parseArgs('file -input="He said \\"hello\\""')
32+
* // => ['file', '-input=He said "hello"']
33+
*
34+
* parseArgs('file -input="unclosed')
35+
* // => ['file', '-input=unclosed'] (unclosed quote)
36+
* ```
37+
*/
38+
export function parseArgs(cmdline: string): string[] {
39+
const args: string[] = [];
40+
let current = "";
41+
let inQuote: string | null = null;
42+
let escaped = false;
43+
44+
for (const char of cmdline) {
45+
if (escaped) {
46+
current += char;
47+
escaped = false;
48+
} else if (char === "\\") {
49+
escaped = true;
50+
} else if (char === '"' || char === "'") {
51+
if (inQuote === char) {
52+
inQuote = null;
53+
} else if (inQuote === null) {
54+
inQuote = char;
55+
} else {
56+
current += char;
57+
}
58+
} else if (char === " " && inQuote === null) {
59+
if (current) {
60+
args.push(current);
61+
current = "";
62+
}
63+
} else {
64+
current += char;
65+
}
66+
}
67+
68+
if (current) {
69+
args.push(current);
70+
}
71+
72+
return args;
73+
}
74+
75+
/**
76+
* Extract option arguments from argument list.
77+
*
78+
* All arguments with the matching prefix are extracted and returned as an array.
79+
* The caller can decide whether to use the first, last, or all values.
80+
*
81+
* @param args - Array of arguments to search
82+
* @param prefix - Option prefix to extract (e.g., '-input=')
83+
* @returns Tuple of [array of extracted values, remaining arguments]
84+
*
85+
* @example
86+
* ```ts
87+
* extractOption(['-input=Hello', 'file', '/path'], '-input=')
88+
* // => [['Hello'], ['file', '/path']]
89+
*
90+
* extractOption(['file', '/path'], '-input=')
91+
* // => [[], ['file', '/path']]
92+
*
93+
* extractOption(['-input=first', 'file', '-input=second'], '-input=')
94+
* // => [['first', 'second'], ['file']]
95+
* ```
96+
*/
97+
export function extractOption(
98+
args: readonly string[],
99+
prefix: string,
100+
): [string[], string[]] {
101+
const values: string[] = [];
102+
const remaining: string[] = [];
103+
104+
for (const arg of args) {
105+
if (arg.startsWith(prefix)) {
106+
values.push(arg.slice(prefix.length));
107+
} else {
108+
remaining.push(arg);
109+
}
110+
}
111+
112+
return [values, remaining];
113+
}

denops/fall/util/args_test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { assertEquals } from "jsr:@std/assert@^1.0.10";
2+
import { extractOption, parseArgs } from "./args.ts";
3+
4+
Deno.test("parseArgs - simple arguments", () => {
5+
assertEquals(parseArgs("file /path/to/dir"), ["file", "/path/to/dir"]);
6+
});
7+
8+
Deno.test("parseArgs - with double quotes", () => {
9+
assertEquals(
10+
parseArgs('file -input="Hello world"'),
11+
["file", "-input=Hello world"],
12+
);
13+
});
14+
15+
Deno.test("parseArgs - with single quotes", () => {
16+
assertEquals(
17+
parseArgs("file -input='Hello world'"),
18+
["file", "-input=Hello world"],
19+
);
20+
});
21+
22+
Deno.test("parseArgs - with escaped quotes", () => {
23+
assertEquals(
24+
parseArgs('file -input="Hello \\"world\\""'),
25+
["file", '-input=Hello "world"'],
26+
);
27+
});
28+
29+
Deno.test("parseArgs - multiple arguments with quotes", () => {
30+
assertEquals(
31+
parseArgs('grep "search term" /path'),
32+
["grep", "search term", "/path"],
33+
);
34+
});
35+
36+
Deno.test("parseArgs - empty string", () => {
37+
assertEquals(parseArgs(""), []);
38+
});
39+
40+
Deno.test("parseArgs - only spaces", () => {
41+
assertEquals(parseArgs(" "), []);
42+
});
43+
44+
Deno.test("parseArgs - mixed quotes", () => {
45+
assertEquals(
46+
parseArgs(`file -input="He said 'hello'"`),
47+
["file", "-input=He said 'hello'"],
48+
);
49+
});
50+
51+
Deno.test("parseArgs - nested different quotes", () => {
52+
assertEquals(
53+
parseArgs(`file -input='She said "hi"'`),
54+
["file", '-input=She said "hi"'],
55+
);
56+
});
57+
58+
Deno.test("extractOption - extracts single option", () => {
59+
const [values, remaining] = extractOption(
60+
["-input=Hello", "file", "/path"],
61+
"-input=",
62+
);
63+
assertEquals(values, ["Hello"]);
64+
assertEquals(remaining, ["file", "/path"]);
65+
});
66+
67+
Deno.test("extractOption - handles missing option", () => {
68+
const [values, remaining] = extractOption(["file", "/path"], "-input=");
69+
assertEquals(values, []);
70+
assertEquals(remaining, ["file", "/path"]);
71+
});
72+
73+
Deno.test("extractOption - handles multiple occurrences", () => {
74+
const [values, remaining] = extractOption(
75+
["-input=first", "file", "-input=second"],
76+
"-input=",
77+
);
78+
assertEquals(values, ["first", "second"]);
79+
assertEquals(remaining, ["file"]);
80+
});
81+
82+
Deno.test("extractOption - handles empty value", () => {
83+
const [values, remaining] = extractOption(["-input=", "file"], "-input=");
84+
assertEquals(values, [""]);
85+
assertEquals(remaining, ["file"]);
86+
});
87+
88+
Deno.test("extractOption - with complex arguments", () => {
89+
const [values, remaining] = extractOption(
90+
["grep", "-input=test", "/path/to/file", "-other=value"],
91+
"-input=",
92+
);
93+
assertEquals(values, ["test"]);
94+
assertEquals(remaining, ["grep", "/path/to/file", "-other=value"]);
95+
});
96+
97+
Deno.test("extractOption - preserves order of extracted values", () => {
98+
const [values, remaining] = extractOption(
99+
["-input=first", "file", "-input=second", "-input=third", "path"],
100+
"-input=",
101+
);
102+
assertEquals(values, ["first", "second", "third"]);
103+
assertEquals(remaining, ["file", "path"]);
104+
});
105+
106+
// Integration tests for picker:command specification
107+
Deno.test("Integration - -input= before source name is used", () => {
108+
const cmdline = '-input="Hello world" file /path';
109+
const allArgs = parseArgs(cmdline);
110+
111+
const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-"));
112+
const beforeSourceArgs = allArgs.slice(0, sourceIndex);
113+
const afterSourceArgs = allArgs.slice(sourceIndex);
114+
const [inputValues] = extractOption(beforeSourceArgs, "-input=");
115+
const [name, ...sourceArgs] = afterSourceArgs;
116+
117+
assertEquals(inputValues, ["Hello world"]);
118+
assertEquals(inputValues.at(-1), "Hello world");
119+
assertEquals(name, "file");
120+
assertEquals(sourceArgs, ["/path"]);
121+
});
122+
123+
Deno.test("Integration - -input= after source name becomes source arg", () => {
124+
const cmdline = 'file -input="test" /path';
125+
const allArgs = parseArgs(cmdline);
126+
127+
const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-"));
128+
const beforeSourceArgs = allArgs.slice(0, sourceIndex);
129+
const afterSourceArgs = allArgs.slice(sourceIndex);
130+
const [inputValues] = extractOption(beforeSourceArgs, "-input=");
131+
const [name, ...sourceArgs] = afterSourceArgs;
132+
133+
assertEquals(inputValues, []); // Not extracted
134+
assertEquals(inputValues.at(-1), undefined);
135+
assertEquals(name, "file");
136+
assertEquals(sourceArgs, ["-input=test", "/path"]); // Treated as source arg
137+
});
138+
139+
Deno.test("Integration - multiple -input= before source (last wins)", () => {
140+
const cmdline = '-input="first" -input="second" file';
141+
const allArgs = parseArgs(cmdline);
142+
143+
const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-"));
144+
const beforeSourceArgs = allArgs.slice(0, sourceIndex);
145+
const afterSourceArgs = allArgs.slice(sourceIndex);
146+
const [inputValues] = extractOption(beforeSourceArgs, "-input=");
147+
const [name, ...sourceArgs] = afterSourceArgs;
148+
149+
assertEquals(inputValues, ["first", "second"]);
150+
assertEquals(inputValues.at(-1), "second"); // Last one wins
151+
assertEquals(name, "file");
152+
assertEquals(sourceArgs, []);
153+
});
154+
155+
Deno.test("Integration - no source name throws error", () => {
156+
const cmdline = '-input="test"';
157+
const allArgs = parseArgs(cmdline);
158+
159+
const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-"));
160+
161+
assertEquals(sourceIndex, -1); // Should trigger error
162+
});
163+
164+
Deno.test("Integration - empty -input= value is allowed", () => {
165+
const cmdline = '-input= file';
166+
const allArgs = parseArgs(cmdline);
167+
168+
const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-"));
169+
const beforeSourceArgs = allArgs.slice(0, sourceIndex);
170+
const afterSourceArgs = allArgs.slice(sourceIndex);
171+
const [inputValues] = extractOption(beforeSourceArgs, "-input=");
172+
const [name, ...sourceArgs] = afterSourceArgs;
173+
174+
assertEquals(inputValues, [""]);
175+
assertEquals(inputValues.at(-1), "");
176+
assertEquals(name, "file");
177+
assertEquals(sourceArgs, []);
178+
});

0 commit comments

Comments
 (0)