diff --git a/deno.jsonc b/deno.jsonc index ed54e7e..15670a2 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -4,6 +4,11 @@ ".coverage/**", ".worktrees/**" ], + "lint": { + "rules": { + "exclude": ["no-import-prefix"] + } + }, "tasks": { "check": "deno check ./**/*.ts", "test": "deno test -A --parallel --shuffle --doc", diff --git a/denops/fall/main/picker.ts b/denops/fall/main/picker.ts index 412eb8c..851c678 100644 --- a/denops/fall/main/picker.ts +++ b/denops/fall/main/picker.ts @@ -1,6 +1,6 @@ import type { Denops, Entrypoint } from "jsr:@denops/std@^7.3.2"; import { ensurePromise } from "jsr:@core/asyncutil@^1.2.0/ensure-promise"; -import { assert, ensure, is } from "jsr:@core/unknownutil@^4.3.0"; +import { assert, is } from "jsr:@core/unknownutil@^4.3.0"; import type { Detail } from "jsr:@vim-fall/core@^0.3.0/item"; import type { PickerParams } from "../custom.ts"; @@ -12,6 +12,7 @@ import { loadUserCustom, } from "../custom.ts"; import { isOptions, isPickerParams, isStringArray } from "../util/predicate.ts"; +import { extractOption, parseArgs } from "../util/args.ts"; import { action as buildActionSource } from "../extension/source/action.ts"; import { Picker, type PickerContext } from "../picker.ts"; import type { SubmatchContext } from "./submatch.ts"; @@ -34,6 +35,32 @@ const SESSION_EXCLUDE_SOURCES = [ "@session", ]; +/** + * Create initial picker context with the specified query. + * + * All fields except query are initialized to their default values: + * - Empty selection, collections, and filtered items + * - Cursor and offset at 0 + * - All component indices at 0 (except previewerIndex which is undefined) + * + * @param query - Initial query string for the picker prompt + * @returns PickerContext with default values + */ +function createInitialContext(query: string): PickerContext { + return { + query, + selection: new Set(), + collectedItems: [], + filteredItems: [], + cursor: 0, + offset: 0, + matcherIndex: 0, + sorterIndex: 0, + rendererIndex: 0, + previewerIndex: undefined, + }; +} + export const main: Entrypoint = (denops) => { denops.dispatcher = { ...denops.dispatcher, @@ -43,12 +70,36 @@ export const main: Entrypoint = (denops) => { assert(options, isOptions); return startPicker(denops, args, itemPickerParams, options); }, - "picker:command": withHandleError(denops, async (args) => { + "picker:command": withHandleError(denops, async (cmdline) => { await loadUserCustom(denops); - // Split the command arguments - const [name, ...sourceArgs] = ensure(args, isStringArray); - // Load user custom + // Parse command line arguments + // cmdline is string from denops#request('fall', 'picker:command', [a:args]) + assert(cmdline, is.String); + const allArgs = parseArgs(cmdline); + + // Find the first non-option argument (source name) + const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-")); + if (sourceIndex === -1) { + throw new ExpectedError( + `Picker name is required. Available item pickers are: ${ + listPickerNames().join(", ") + }`, + ); + } + + // Extract -input= option only from arguments before the source name + const beforeSourceArgs = allArgs.slice(0, sourceIndex); + const afterSourceArgs = allArgs.slice(sourceIndex); + const [inputValues] = extractOption(beforeSourceArgs, "-input="); + const initialQuery = inputValues.at(-1); + // Note: Currently only -input= is supported. Other options before + // the source name are silently ignored for future extensibility. + + // Get source name and its arguments + const [name, ...sourceArgs] = afterSourceArgs; + + // Load picker params const itemPickerParams = getPickerParams(name); if (!itemPickerParams) { throw new ExpectedError( @@ -57,11 +108,17 @@ export const main: Entrypoint = (denops) => { }`, ); } + + // Create context with initial query if specified + const context = initialQuery !== undefined + ? createInitialContext(initialQuery) + : undefined; + await startPicker( denops, sourceArgs, itemPickerParams, - { signal: denops.interrupted }, + { signal: denops.interrupted, context }, ); }), "picker:command:complete": withHandleError( diff --git a/denops/fall/util/args.ts b/denops/fall/util/args.ts new file mode 100644 index 0000000..0c61bba --- /dev/null +++ b/denops/fall/util/args.ts @@ -0,0 +1,113 @@ +/** + * Utility functions for parsing and processing command-line arguments. + * @module + */ + +/** + * Parse command line arguments respecting quotes and escapes. + * + * This function properly handles: + * - Double quotes (`"`) + * - Single quotes (`'`) + * - Escape sequences (`\`) + * - Nested quotes of different types + * + * Note on edge cases: + * - Unclosed quotes: treated as part of the argument value + * - Trailing backslash: ignored (escape with no following character) + * - Empty string or only spaces: returns empty array + * + * @param cmdline - The command line string to parse + * @returns Array of parsed arguments + * + * @example + * ```ts + * parseArgs('file -input="Hello world"') + * // => ['file', '-input=Hello world'] + * + * parseArgs("file -input='test'") + * // => ['file', '-input=test'] + * + * parseArgs('file -input="He said \\"hello\\""') + * // => ['file', '-input=He said "hello"'] + * + * parseArgs('file -input="unclosed') + * // => ['file', '-input=unclosed'] (unclosed quote) + * ``` + */ +export function parseArgs(cmdline: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + let escaped = false; + + for (const char of cmdline) { + if (escaped) { + current += char; + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"' || char === "'") { + if (inQuote === char) { + inQuote = null; + } else if (inQuote === null) { + inQuote = char; + } else { + current += char; + } + } else if (char === " " && inQuote === null) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +/** + * Extract option arguments from argument list. + * + * All arguments with the matching prefix are extracted and returned as an array. + * The caller can decide whether to use the first, last, or all values. + * + * @param args - Array of arguments to search + * @param prefix - Option prefix to extract (e.g., '-input=') + * @returns Tuple of [array of extracted values, remaining arguments] + * + * @example + * ```ts + * extractOption(['-input=Hello', 'file', '/path'], '-input=') + * // => [['Hello'], ['file', '/path']] + * + * extractOption(['file', '/path'], '-input=') + * // => [[], ['file', '/path']] + * + * extractOption(['-input=first', 'file', '-input=second'], '-input=') + * // => [['first', 'second'], ['file']] + * ``` + */ +export function extractOption( + args: readonly string[], + prefix: string, +): [string[], string[]] { + const values: string[] = []; + const remaining: string[] = []; + + for (const arg of args) { + if (arg.startsWith(prefix)) { + values.push(arg.slice(prefix.length)); + } else { + remaining.push(arg); + } + } + + return [values, remaining]; +} diff --git a/denops/fall/util/args_test.ts b/denops/fall/util/args_test.ts new file mode 100644 index 0000000..62c398e --- /dev/null +++ b/denops/fall/util/args_test.ts @@ -0,0 +1,178 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.10"; +import { extractOption, parseArgs } from "./args.ts"; + +Deno.test("parseArgs - simple arguments", () => { + assertEquals(parseArgs("file /path/to/dir"), ["file", "/path/to/dir"]); +}); + +Deno.test("parseArgs - with double quotes", () => { + assertEquals( + parseArgs('file -input="Hello world"'), + ["file", "-input=Hello world"], + ); +}); + +Deno.test("parseArgs - with single quotes", () => { + assertEquals( + parseArgs("file -input='Hello world'"), + ["file", "-input=Hello world"], + ); +}); + +Deno.test("parseArgs - with escaped quotes", () => { + assertEquals( + parseArgs('file -input="Hello \\"world\\""'), + ["file", '-input=Hello "world"'], + ); +}); + +Deno.test("parseArgs - multiple arguments with quotes", () => { + assertEquals( + parseArgs('grep "search term" /path'), + ["grep", "search term", "/path"], + ); +}); + +Deno.test("parseArgs - empty string", () => { + assertEquals(parseArgs(""), []); +}); + +Deno.test("parseArgs - only spaces", () => { + assertEquals(parseArgs(" "), []); +}); + +Deno.test("parseArgs - mixed quotes", () => { + assertEquals( + parseArgs(`file -input="He said 'hello'"`), + ["file", "-input=He said 'hello'"], + ); +}); + +Deno.test("parseArgs - nested different quotes", () => { + assertEquals( + parseArgs(`file -input='She said "hi"'`), + ["file", '-input=She said "hi"'], + ); +}); + +Deno.test("extractOption - extracts single option", () => { + const [values, remaining] = extractOption( + ["-input=Hello", "file", "/path"], + "-input=", + ); + assertEquals(values, ["Hello"]); + assertEquals(remaining, ["file", "/path"]); +}); + +Deno.test("extractOption - handles missing option", () => { + const [values, remaining] = extractOption(["file", "/path"], "-input="); + assertEquals(values, []); + assertEquals(remaining, ["file", "/path"]); +}); + +Deno.test("extractOption - handles multiple occurrences", () => { + const [values, remaining] = extractOption( + ["-input=first", "file", "-input=second"], + "-input=", + ); + assertEquals(values, ["first", "second"]); + assertEquals(remaining, ["file"]); +}); + +Deno.test("extractOption - handles empty value", () => { + const [values, remaining] = extractOption(["-input=", "file"], "-input="); + assertEquals(values, [""]); + assertEquals(remaining, ["file"]); +}); + +Deno.test("extractOption - with complex arguments", () => { + const [values, remaining] = extractOption( + ["grep", "-input=test", "/path/to/file", "-other=value"], + "-input=", + ); + assertEquals(values, ["test"]); + assertEquals(remaining, ["grep", "/path/to/file", "-other=value"]); +}); + +Deno.test("extractOption - preserves order of extracted values", () => { + const [values, remaining] = extractOption( + ["-input=first", "file", "-input=second", "-input=third", "path"], + "-input=", + ); + assertEquals(values, ["first", "second", "third"]); + assertEquals(remaining, ["file", "path"]); +}); + +// Integration tests for picker:command specification +Deno.test("Integration - -input= before source name is used", () => { + const cmdline = '-input="Hello world" file /path'; + const allArgs = parseArgs(cmdline); + + const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-")); + const beforeSourceArgs = allArgs.slice(0, sourceIndex); + const afterSourceArgs = allArgs.slice(sourceIndex); + const [inputValues] = extractOption(beforeSourceArgs, "-input="); + const [name, ...sourceArgs] = afterSourceArgs; + + assertEquals(inputValues, ["Hello world"]); + assertEquals(inputValues.at(-1), "Hello world"); + assertEquals(name, "file"); + assertEquals(sourceArgs, ["/path"]); +}); + +Deno.test("Integration - -input= after source name becomes source arg", () => { + const cmdline = 'file -input="test" /path'; + const allArgs = parseArgs(cmdline); + + const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-")); + const beforeSourceArgs = allArgs.slice(0, sourceIndex); + const afterSourceArgs = allArgs.slice(sourceIndex); + const [inputValues] = extractOption(beforeSourceArgs, "-input="); + const [name, ...sourceArgs] = afterSourceArgs; + + assertEquals(inputValues, []); // Not extracted + assertEquals(inputValues.at(-1), undefined); + assertEquals(name, "file"); + assertEquals(sourceArgs, ["-input=test", "/path"]); // Treated as source arg +}); + +Deno.test("Integration - multiple -input= before source (last wins)", () => { + const cmdline = '-input="first" -input="second" file'; + const allArgs = parseArgs(cmdline); + + const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-")); + const beforeSourceArgs = allArgs.slice(0, sourceIndex); + const afterSourceArgs = allArgs.slice(sourceIndex); + const [inputValues] = extractOption(beforeSourceArgs, "-input="); + const [name, ...sourceArgs] = afterSourceArgs; + + assertEquals(inputValues, ["first", "second"]); + assertEquals(inputValues.at(-1), "second"); // Last one wins + assertEquals(name, "file"); + assertEquals(sourceArgs, []); +}); + +Deno.test("Integration - no source name throws error", () => { + const cmdline = '-input="test"'; + const allArgs = parseArgs(cmdline); + + const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-")); + + assertEquals(sourceIndex, -1); // Should trigger error +}); + +Deno.test("Integration - empty -input= value is allowed", () => { + const cmdline = "-input= file"; + const allArgs = parseArgs(cmdline); + + const sourceIndex = allArgs.findIndex((arg) => !arg.startsWith("-")); + const beforeSourceArgs = allArgs.slice(0, sourceIndex); + const afterSourceArgs = allArgs.slice(sourceIndex); + const [inputValues] = extractOption(beforeSourceArgs, "-input="); + const [name, ...sourceArgs] = afterSourceArgs; + + assertEquals(inputValues, [""]); + assertEquals(inputValues.at(-1), ""); + assertEquals(name, "file"); + assertEquals(sourceArgs, []); +}); diff --git a/doc/fall.txt b/doc/fall.txt index d9b80e0..8099aae 100644 --- a/doc/fall.txt +++ b/doc/fall.txt @@ -66,6 +66,15 @@ To filter lines within the current buffer, use the "line" source: > :Fall line < +To set an initial input value for the prompt, use the "-input=" option before +the source name: +> + :Fall -input="Hello world" file +< +This is useful for starting with a pre-filled search query. If you need to +include spaces in the input, use quotes around the value. Note that the +"-input=" option must appear before the source name to take effect. + Sources are defined in "custom.ts" via |:FallCustom|. In default "custom.ts", the following sources are defined: @@ -398,15 +407,31 @@ INTERFACE *fall-interface* COMMAND *fall-command* *:Fall* -:Fall {source} [{cmdarg}] +:Fall [-input={value}] {source} [{cmdarg}...] Open picker to filter {source} (defined in "custom.ts" via |:FallCustom|). {cmdarg} is passed to the source. + + The "-input=" option sets the initial input value for the picker's + prompt. This is useful for starting with a pre-filled search query. + When the value contains spaces, enclose it in quotes. + + Note: The "-input=" option must appear BEFORE the source name. + Options after the source name are treated as source arguments. > " Open picker to filter files in the current directory :Fall file " Open picker to filter files in the specified directory :Fall file /path/to/directory + + " Open picker with initial input value + :Fall -input="Hello world" file + + " Combine with source arguments + :Fall -input="search term" grep /path/to/dir + + " Invalid: -input= after source name (treated as source argument) + :Fall file -input="test" < *:FallResume* diff --git a/plugin/fall.vim b/plugin/fall.vim index 515d07e..c9db22d 100644 --- a/plugin/fall.vim +++ b/plugin/fall.vim @@ -4,8 +4,8 @@ endif let g:loaded_fall = 1 let s:sep = has('win32') ? '\' : '/' -command! -nargs=+ -complete=customlist,fall#command#Fall#complete - \ Fall call fall#command#Fall#call([]) +command! -nargs=+ -complete=customlist,fall#command#Fall#complete + \ Fall call fall#command#Fall#call() command! -nargs=? -complete=customlist,fall#command#FallResume#complete \ FallResume call fall#command#FallResume#call() command! -nargs=0 FallSession call fall#command#FallSession#call()