Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
".coverage/**",
".worktrees/**"
],
"lint": {
"rules": {
"exclude": ["no-import-prefix"]
}
},
"tasks": {
"check": "deno check ./**/*.ts",
"test": "deno test -A --parallel --shuffle --doc",
Expand Down
69 changes: 63 additions & 6 deletions denops/fall/main/picker.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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<Detail> {
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,
Expand All @@ -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(
Expand All @@ -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(
Expand Down
113 changes: 113 additions & 0 deletions denops/fall/util/args.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading