diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba3b95c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. + +## Project Overview + +This is `@vim-fall/std`, the standard library for Fall - a fuzzy finder plugin +for Vim/Neovim powered by Denops. The project provides built-in extensions and +utility functions for the Fall ecosystem, written in Deno/TypeScript. + +## Key Commands + +### Development + +- **Type check**: `deno task check` - Run TypeScript type checking +- **Test**: `deno test -A --parallel --shuffle --doc` - Run all tests +- **Test single file**: `deno test -A path/to/file_test.ts` - Run specific test + file +- **Test with coverage**: `deno task test:coverage` - Run tests with coverage + collection +- **Lint**: `deno lint` - Run Deno linter +- **Format**: `deno fmt` - Auto-format code +- **Format check**: `deno fmt --check` - Check formatting without modifying + +### Code Generation + +- **Generate all**: `deno task gen` - Run all code generation tasks +- **Generate modules**: `deno task gen:mod` - Generate module files in each + directory +- **Generate exports**: `deno task gen:exports` - Generate export declarations +- **Generate nerdfont**: `deno task gen:builtin-renderer-nerdfont` - Generate + nerdfont icon mappings + +### Dependencies + +- **Update check**: `deno task update` - Check for dependency updates +- **Update write**: `deno task update:write` - Update dependencies in place +- **Update commit**: `deno task update:commit` - Update and commit dependency + changes + +### Publishing + +- **Dry run**: `deno publish --dry-run` - Test JSR publishing without publishing + +## Architecture + +### Module Organization + +The codebase follows a plugin-based architecture with these core concepts: + +1. **Extension Types** (in `/builtin/`): + + - **Actions**: Operations performed on selected items (open, yank, cd) + - **Coordinators**: UI layout managers (compact, modern, separate) + - **Curators**: Search/filter tools wrapping external commands (grep, + ripgrep, git-grep) + - **Matchers**: Fuzzy matching algorithms (fzf, regexp, substring) + - **Previewers**: Item preview handlers (file, buffer, helptag) + - **Refiners**: Item transformation/filtering tools + - **Renderers**: Display formatters (nerdfont icons, path formatting) + - **Sorters**: Sorting algorithms (lexical, numerical) + - **Sources**: Data providers (files, buffers, quickfix, history) + - **Themes**: UI border styles (ascii, modern, single, double) + +2. **Core APIs** (in root): + + - Each extension type has a corresponding definition file (e.g., `action.ts`, + `source.ts`) + - These files define the interface and helper functions for that extension + type + - The `mod.ts` file re-exports utility functions for composing extensions + +3. **Denops Integration**: + - All extensions receive a `Denops` instance for Vim/Neovim interaction + - Uses `@denops/std` for Vim API access + - Test files use `@denops/test` for testing with real Vim instances + +### Key Patterns + +1. **Extension Definition**: Use `define*` functions (e.g., `defineAction`, + `defineSource`) +2. **Composition**: Use `compose*` functions to combine multiple extensions +3. **Type Safety**: Extensive use of TypeScript generics for item detail types +4. **Async/Streaming**: Sources use async generators for efficient data + streaming +5. **Parameter Binding**: `ArgsBinder` pattern for flexible parameter handling + +### Testing Approach + +- Tests use Deno's built-in test runner with `Deno.test()` +- Integration tests use real Vim/Neovim instances via `@denops/test` +- Test files follow `*_test.ts` naming convention +- Use `@std/assert` for assertions + +### Libraries + +- Use `@core/unknownutil` for type-guarding unknown types + - Note that if you use `@denops/std/function`, some functions already provides + proper types so you may not need to use `@core/unknownutil` + - You should try hard to avoid using `as any` or `as unkonwn as`. Proper + type-guarding is preferred. +- Use `@denops/std` for Vim/Neovim API access. diff --git a/builtin/previewer/mod.ts b/builtin/previewer/mod.ts index e48a88e..0c76597 100644 --- a/builtin/previewer/mod.ts +++ b/builtin/previewer/mod.ts @@ -3,3 +3,4 @@ export * from "./buffer.ts"; export * from "./file.ts"; export * from "./helptag.ts"; export * from "./noop.ts"; +export * from "./shell.ts"; diff --git a/builtin/previewer/shell.ts b/builtin/previewer/shell.ts new file mode 100644 index 0000000..aadc467 --- /dev/null +++ b/builtin/previewer/shell.ts @@ -0,0 +1,163 @@ +import { unnullish } from "@lambdalisue/unnullish"; + +import { definePreviewer, type Previewer } from "../../previewer.ts"; +import { splitText } from "../../util/stringutil.ts"; + +type Detail = { + /** + * Command to execute + */ + command?: string; + + /** + * Arguments to pass to the command + */ + args?: string[]; + + /** + * Current working directory for command execution + */ + cwd?: string; + + /** + * Environment variables + */ + env?: Record; + + /** + * Timeout in milliseconds + */ + timeout?: number; +}; + +export type ShellOptions = { + /** + * Default shell to use if no command is specified. + * @default ["sh", "-c"] + */ + shell?: string[]; + + /** + * Default timeout in milliseconds. + * @default 5000 + */ + defaultTimeout?: number; + + /** + * Maximum number of lines to display. + * @default 1000 + */ + maxLines?: number; +}; + +/** + * Creates a Previewer that executes shell commands and displays their output. + * + * This Previewer runs a specified command and shows its stdout/stderr output. + * It supports custom working directories, environment variables, and timeouts. + * + * @param options - Options to customize shell command execution. + * @returns A Previewer that shows the command output. + */ +export function shell(options: Readonly = {}): Previewer { + const shell = options.shell ?? ["sh", "-c"]; + const defaultTimeout = options.defaultTimeout ?? 5000; + const maxLines = options.maxLines ?? 1000; + + return definePreviewer(async (_denops, { item }, { signal }) => { + // Get command from detail or use item value as command + const command = item.detail.command ?? item.value; + const args = item.detail.args ?? []; + const cwd = item.detail.cwd; + const env = item.detail.env; + const timeout = item.detail.timeout ?? defaultTimeout; + + // Prepare command array + let cmd: string[]; + if (args.length > 0) { + cmd = [command, ...args]; + } else { + // Use shell to execute the command string + cmd = [...shell, command]; + } + + try { + // Create subprocess + const process = new Deno.Command(cmd[0], { + args: cmd.slice(1), + cwd: unnullish(cwd, (v) => v), + env: unnullish(env, (v) => v), + stdout: "piped", + stderr: "piped", + signal, + }); + + // Set up timeout + const timeoutId = setTimeout(() => { + try { + process.spawn().kill(); + } catch { + // Ignore errors when killing + } + }, timeout); + + try { + // Execute command + const { stdout, stderr, success } = await process.output(); + clearTimeout(timeoutId); + + // Decode output + const decoder = new TextDecoder(); + const stdoutText = decoder.decode(stdout); + const stderrText = decoder.decode(stderr); + + // Combine stdout and stderr + let content: string[] = []; + + if (stdoutText) { + content.push(...splitText(stdoutText)); + } + + if (stderrText) { + if (content.length > 0) { + content.push("--- stderr ---"); + } + content.push(...splitText(stderrText)); + } + + // Add status line if command failed + if (!success) { + content.push("", `[Command failed with non-zero exit code]`); + } + + // Limit output lines + if (content.length > maxLines) { + content = content.slice(0, maxLines); + content.push("", `[Output truncated to ${maxLines} lines]`); + } + + // Handle empty output + if (content.length === 0) { + content = ["[No output]"]; + } + + return { + content, + filename: `$ ${cmd.join(" ")}`, + }; + } finally { + clearTimeout(timeoutId); + } + } catch (err) { + // Handle command execution errors + return { + content: [ + `Error executing command: ${command}`, + "", + ...String(err).split("\n"), + ], + filename: `$ ${cmd.join(" ")}`, + }; + } + }); +} diff --git a/builtin/refiner/buffer_info.ts b/builtin/refiner/buffer_info.ts new file mode 100644 index 0000000..ac377fb --- /dev/null +++ b/builtin/refiner/buffer_info.ts @@ -0,0 +1,195 @@ +import * as fn from "@denops/std/function"; +import type { IdItem } from "@vim-fall/core/item"; +import { defineRefiner, type Refiner } from "../../refiner.ts"; + +type Detail = { + bufnr: number; +}; + +export type BufferInfoRefinerOptions = { + /** + * Filter by buffer modification status. + */ + modified?: boolean; + + /** + * Filter by buffer listed status. + */ + listed?: boolean; + + /** + * Filter by buffer loaded status. + */ + loaded?: boolean; + + /** + * Filter by buffer visibility (visible in any window). + */ + visible?: boolean; + + /** + * Filter by file types. + * If provided, only buffers with these filetypes will pass. + */ + filetypes?: string[]; + + /** + * Filter by buffer types. + * Common values: "", "help", "quickfix", "terminal", "prompt", "popup", "nofile", "nowrite", "acwrite" + */ + buftypes?: string[]; + + /** + * Whether to include unnamed buffers. + * @default true + */ + includeUnnamed?: boolean; + + /** + * Whether to include special buffers (help, quickfix, etc). + * @default true + */ + includeSpecial?: boolean; + + /** + * Minimum line count for the buffer. + */ + minLines?: number; + + /** + * Maximum line count for the buffer. + */ + maxLines?: number; +}; + +/** + * Creates a Refiner that filters items based on buffer information. + * + * This Refiner can filter buffers based on various criteria such as + * modification status, visibility, file type, and buffer properties. + * + * @param options - Options to customize buffer filtering. + * @returns A Refiner that filters items based on buffer information. + */ +export function bufferInfo( + options: Readonly = {}, +): Refiner { + const modified = options.modified; + const listed = options.listed; + const loaded = options.loaded; + const visible = options.visible; + const filetypes = options.filetypes; + const buftypes = options.buftypes; + const includeUnnamed = options.includeUnnamed ?? true; + const includeSpecial = options.includeSpecial ?? true; + const minLines = options.minLines; + const maxLines = options.maxLines; + + return defineRefiner(async function* (denops, { items }) { + // Convert async iterable to array first + const itemsArray: IdItem[] = []; + for await (const item of items) { + itemsArray.push(item); + } + + // Get all buffer info at once for efficiency + const allBufinfo = await fn.getbufinfo(denops); + const bufInfoMap = new Map( + allBufinfo.map((info) => [info.bufnr, info]), + ); + + // Process items and filter + const results = await Promise.all( + itemsArray.map(async (item) => { + const { bufnr } = item.detail; + const bufinfo = bufInfoMap.get(bufnr); + + if (!bufinfo) { + return null; + } + + // Check modification status + if (modified !== undefined && !!bufinfo.changed !== modified) { + return null; + } + + // Check listed status + if (listed !== undefined && !!bufinfo.listed !== listed) { + return null; + } + + // Check loaded status + if (loaded !== undefined && !!bufinfo.loaded !== loaded) { + return null; + } + + // Check visibility + if (visible !== undefined) { + const isVisible = bufinfo.windows && bufinfo.windows.length > 0; + if (visible !== isVisible) { + return null; + } + } + + // Check unnamed buffers + if (!includeUnnamed && !bufinfo.name) { + return null; + } + + // Check line count + if (minLines !== undefined && bufinfo.linecount < minLines) { + return null; + } + if (maxLines !== undefined && bufinfo.linecount > maxLines) { + return null; + } + + // Check filetype + if (filetypes && filetypes.length > 0) { + const filetype = await denops.eval( + `getbufvar(${bufnr}, "&filetype")`, + ) as string; + if (!filetypes.includes(filetype)) { + return null; + } + } + + // Check buftype + if (buftypes && buftypes.length > 0) { + const buftype = await denops.eval( + `getbufvar(${bufnr}, "&buftype")`, + ) as string; + if (!buftypes.includes(buftype)) { + return null; + } + } + + // Check special buffers + if (!includeSpecial) { + const buftype = await denops.eval( + `getbufvar(${bufnr}, "&buftype")`, + ) as string; + if (buftype && buftype !== "") { + return null; + } + + // Also check for help buffers + const filetype = await denops.eval( + `getbufvar(${bufnr}, "&filetype")`, + ) as string; + if (filetype === "help") { + return null; + } + } + + return item; + }), + ); + + // Return only non-null items + const filtered = results.filter((item): item is IdItem => + item !== null + ); + yield* filtered; + }); +} diff --git a/builtin/refiner/file_info.ts b/builtin/refiner/file_info.ts new file mode 100644 index 0000000..d4ce1c0 --- /dev/null +++ b/builtin/refiner/file_info.ts @@ -0,0 +1,189 @@ +import type { IdItem } from "@vim-fall/core/item"; +import { defineRefiner, type Refiner } from "../../refiner.ts"; +import { extname } from "@std/path/extname"; + +type Detail = { + /** + * File path + */ + path: string; +}; + +export type FileInfoRefinerOptions = { + /** + * Filter by file extensions. + * If provided, only files with these extensions will pass. + */ + extensions?: string[]; + + /** + * Filter by file size. + * Files must be within this range (in bytes). + */ + sizeRange?: { + min?: number; + max?: number; + }; + + /** + * Filter by modification time. + * Files must be modified within this time range. + */ + modifiedWithin?: { + days?: number; + hours?: number; + minutes?: number; + }; + + /** + * Whether to include directories. + * @default true + */ + includeDirectories?: boolean; + + /** + * Whether to include files. + * @default true + */ + includeFiles?: boolean; + + /** + * Whether to include symlinks. + * @default true + */ + includeSymlinks?: boolean; + + /** + * Whether to exclude hidden files (starting with dot). + * @default false + */ + excludeHidden?: boolean; + + /** + * Patterns to exclude (glob patterns). + */ + excludePatterns?: string[]; +}; + +/** + * Creates a Refiner that filters items based on file information. + * + * This Refiner can filter files based on various criteria such as + * file extension, size, modification time, and file type. + * + * @param options - Options to customize file filtering. + * @returns A Refiner that filters items based on file information. + */ +export function fileInfo( + options: Readonly = {}, +): Refiner { + const extensions = options.extensions; + const sizeRange = options.sizeRange; + const modifiedWithin = options.modifiedWithin; + const includeDirectories = options.includeDirectories ?? true; + const includeFiles = options.includeFiles ?? true; + const includeSymlinks = options.includeSymlinks ?? true; + const excludeHidden = options.excludeHidden ?? false; + const excludePatterns = options.excludePatterns ?? []; + + return defineRefiner(async function* (_denops, { items }) { + // Convert async iterable to array first + const itemsArray: IdItem[] = []; + for await (const item of items) { + itemsArray.push(item); + } + + // Process items in parallel and filter + const results = await Promise.all( + itemsArray.map(async (item) => { + const { path } = item.detail; + + // Check if hidden file should be excluded + if ( + excludeHidden && + path.split("/").some((part: string) => part.startsWith(".")) + ) { + return null; + } + + // Check exclude patterns + for (const pattern of excludePatterns) { + // Simple glob pattern matching (could be enhanced) + const regex = new RegExp( + pattern.replace(/\*/g, ".*").replace(/\?/g, "."), + ); + if (regex.test(path)) { + return null; + } + } + + // Check extension filter + if (extensions && extensions.length > 0) { + const ext = extname(path).toLowerCase(); + if (!extensions.includes(ext)) { + return null; + } + } + + try { + // Get file stats + const stat = await Deno.stat(path); + + // Check file type filters + if (!includeFiles && stat.isFile) { + return null; + } + if (!includeDirectories && stat.isDirectory) { + return null; + } + if (!includeSymlinks && stat.isSymlink) { + return null; + } + + // Check size filter + if (sizeRange && stat.isFile) { + if (sizeRange.min !== undefined && stat.size < sizeRange.min) { + return null; + } + if (sizeRange.max !== undefined && stat.size > sizeRange.max) { + return null; + } + } + + // Check modification time filter + if (modifiedWithin && stat.mtime) { + const now = new Date(); + const mtime = stat.mtime; + const diffMs = now.getTime() - mtime.getTime(); + + let maxMs = 0; + if (modifiedWithin.days !== undefined) { + maxMs += modifiedWithin.days * 24 * 60 * 60 * 1000; + } + if (modifiedWithin.hours !== undefined) { + maxMs += modifiedWithin.hours * 60 * 60 * 1000; + } + if (modifiedWithin.minutes !== undefined) { + maxMs += modifiedWithin.minutes * 60 * 1000; + } + + if (diffMs > maxMs) { + return null; + } + } + + return item; + } catch { + // If stat fails, exclude the item + return null; + } + }), + ); + + // Return only non-null items + const filtered = results.filter((item): item is IdItem => + item !== null + ); + yield* filtered; + }); +} diff --git a/builtin/refiner/mod.ts b/builtin/refiner/mod.ts index 20e1636..25b15f3 100644 --- a/builtin/refiner/mod.ts +++ b/builtin/refiner/mod.ts @@ -1,7 +1,9 @@ // This file is generated by gen-mod.ts export * from "./absolute_path.ts"; +export * from "./buffer_info.ts"; export * from "./cwd.ts"; export * from "./exists.ts"; +export * from "./file_info.ts"; export * from "./noop.ts"; export * from "./regexp.ts"; export * from "./relative_path.ts"; diff --git a/builtin/renderer/buffer_info.ts b/builtin/renderer/buffer_info.ts new file mode 100644 index 0000000..789f079 --- /dev/null +++ b/builtin/renderer/buffer_info.ts @@ -0,0 +1,163 @@ +import * as fn from "@denops/std/function"; +import { defineRenderer, type Renderer } from "../../renderer.ts"; + +type Detail = { + bufnr: number; +}; + +export type BufferInfoOptions = { + /** + * Which buffer information to display. + * @default ["modified", "type", "line_count"] + */ + fields?: Array<"modified" | "readonly" | "type" | "line_count" | "path">; + + /** + * Whether to show buffer variables. + * @default false + */ + showVariables?: boolean; + + /** + * Whether to colorize output (using ANSI codes). + * @default false + */ + colorize?: boolean; +}; + +/** + * Creates a Renderer that appends buffer information to item labels. + * + * This Renderer adds buffer metadata such as modification status, + * file type, line count, and other properties to each item's label. + * + * @param options - Options to customize buffer info display. + * @returns A Renderer that adds buffer information to item labels. + */ +export function bufferInfo( + options: Readonly = {}, +): Renderer { + const fields = options.fields ?? ["modified", "type", "line_count"]; + const showVariables = options.showVariables ?? false; + const colorize = options.colorize ?? false; + + return defineRenderer(async (denops, { items }) => { + // Process items in parallel + await Promise.all( + items.map(async (item) => { + const { bufnr } = item.detail; + const parts: string[] = []; + + try { + // Get buffer information + const bufinfo = await fn.getbufinfo(denops, bufnr); + if (!bufinfo || bufinfo.length === 0) { + return; + } + + const buf = bufinfo[0]; + + // Add requested fields + for (const field of fields) { + switch (field) { + case "modified": { + if (buf.changed) { + parts.push("[+]"); + } + break; + } + + case "readonly": { + // Check if buffer is readonly + const readonly = await denops.eval( + `getbufvar(${bufnr}, "&readonly")`, + ) as number; + if (readonly) { + parts.push("[RO]"); + } + break; + } + + case "type": { + // Get filetype + const filetype = await denops.eval( + `getbufvar(${bufnr}, "&filetype")`, + ) as string; + if (filetype) { + parts.push(`(${filetype})`); + } + break; + } + + case "line_count": { + const lineCount = buf.linecount; + if (lineCount !== undefined) { + parts.push(`${lineCount}L`); + } + break; + } + + case "path": { + // Get full path + const fullpath = await fn.fnamemodify( + denops, + buf.name, + ":p", + ) as string; + if (fullpath && fullpath !== buf.name) { + parts.push(fullpath); + } + break; + } + } + } + + // Add buffer variables if requested + if (showVariables) { + const varNames = [ + "&modified", + "&readonly", + "&modifiable", + "&buflisted", + ]; + const vars: string[] = []; + + for (const varName of varNames) { + const value = await denops.eval( + `getbufvar(${bufnr}, "${varName}")`, + ); + if (value) { + vars.push(`${varName.substring(1)}=${value}`); + } + } + + if (vars.length > 0) { + parts.push(`{${vars.join(",")}}`); + } + } + + // Combine parts and append to label + if (parts.length > 0) { + const info = parts.join(" "); + if (colorize) { + // Add color based on buffer state + if (buf.changed) { + item.label = `${item.label} \x1b[33m${info}\x1b[0m`; // Yellow for modified + } else if ( + fields.includes("readonly") && parts.includes("[RO]") + ) { + item.label = `${item.label} \x1b[31m${info}\x1b[0m`; // Red for readonly + } else { + item.label = `${item.label} \x1b[90m${info}\x1b[0m`; // Gray for normal + } + } else { + item.label = `${item.label} ${info}`; + } + } + } catch { + // If buffer info fails, skip + } + }), + ); + }); +} diff --git a/builtin/renderer/file_info.ts b/builtin/renderer/file_info.ts new file mode 100644 index 0000000..98437c4 --- /dev/null +++ b/builtin/renderer/file_info.ts @@ -0,0 +1,219 @@ +import { defineRenderer, type Renderer } from "../../renderer.ts"; + +type Detail = { + path: string; +}; + +export type FileInfoOptions = { + /** + * Which file information to display. + * @default ["size", "modified"] + */ + fields?: Array<"size" | "modified" | "permissions" | "type">; + + /** + * Whether to show relative timestamps (e.g., "2 hours ago"). + * @default true + */ + relativeTime?: boolean; + + /** + * Width of each field in characters. + * @default { size: 8, modified: 16, permissions: 10, type: 4 } + */ + fieldWidths?: { + size?: number; + modified?: number; + permissions?: number; + type?: number; + }; + + /** + * Whether to colorize output (using ANSI codes). + * @default false + */ + colorize?: boolean; +}; + +/** + * Creates a Renderer that appends file information to item labels. + * + * This Renderer adds file metadata such as size, modification time, + * permissions, and file type to each item's label in a formatted manner. + * + * @param options - Options to customize file info display. + * @returns A Renderer that adds file information to item labels. + */ +export function fileInfo( + options: Readonly = {}, +): Renderer { + const fields = options.fields ?? ["size", "modified"]; + const relativeTime = options.relativeTime ?? true; + const fieldWidths = { + size: 8, + modified: 16, + permissions: 10, + type: 4, + ...options.fieldWidths, + }; + const colorize = options.colorize ?? false; + + return defineRenderer(async (_denops, { items }) => { + // Process items sequentially to avoid file system issues + for (const item of items) { + const { path } = item.detail; + const parts: string[] = []; + + try { + // Get file stats + const stat = await Deno.stat(path); + + // Add requested fields + for (const field of fields) { + switch (field) { + case "size": { + const sizeStr = stat.isFile + ? formatBytes(stat.size) + : stat.isDirectory + ? "-" + : "0B"; + parts.push(sizeStr.padStart(fieldWidths.size)); + break; + } + + case "modified": { + const mtime = stat.mtime; + let timeStr: string; + if (mtime && relativeTime) { + timeStr = formatRelativeTime(mtime); + } else if (mtime) { + timeStr = formatDate(mtime); + } else { + timeStr = "-"; + } + parts.push(timeStr.padEnd(fieldWidths.modified)); + break; + } + + case "permissions": { + const permsStr = formatPermissions(stat.mode); + parts.push(permsStr.padEnd(fieldWidths.permissions)); + break; + } + + case "type": { + const typeStr = stat.isDirectory + ? "dir" + : stat.isFile + ? "file" + : stat.isSymlink + ? "link" + : "other"; + parts.push(typeStr.padEnd(fieldWidths.type)); + break; + } + } + } + + // Combine parts and append to label + const info = parts.join(" "); + if (colorize) { + // Add color based on file type + if (stat.isDirectory) { + item.label = `${item.label} \x1b[34m${info}\x1b[0m`; + } else if (stat.isSymlink) { + item.label = `${item.label} \x1b[36m${info}\x1b[0m`; + } else if ((stat.mode ?? 0) & 0o111) { + // Executable + item.label = `${item.label} \x1b[32m${info}\x1b[0m`; + } else { + item.label = `${item.label} \x1b[90m${info}\x1b[0m`; + } + } else { + item.label = `${item.label} ${info}`; + } + } catch { + // If stat fails, add placeholder + const placeholder = fields.map((field) => { + const width = fieldWidths[field as keyof typeof fieldWidths] ?? 10; + return "-".padEnd(width); + }).join(" "); + item.label = `${item.label} ${placeholder}`; + } + } + }); +} + +/** + * Formats bytes to a human-readable string. + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return "0B"; + + const units = ["B", "KB", "MB", "GB", "TB"]; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${(bytes / Math.pow(k, i)).toFixed(1)}${units[i]}`; +} + +/** + * Formats a date to a relative time string. + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (years > 0) { + return `${years}y ago`; + } else if (months > 0) { + return `${months}mo ago`; + } else if (days > 0) { + return `${days}d ago`; + } else if (hours > 0) { + return `${hours}h ago`; + } else if (minutes > 0) { + return `${minutes}m ago`; + } else { + return "just now"; + } +} + +/** + * Formats a date to a standard string. + */ +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day} ${hour}:${minute}`; +} + +/** + * Formats file permissions to a Unix-style string. + */ +function formatPermissions(mode: number | null): string { + if (mode === null) { + return "---------"; + } + + const perms = []; + const types = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"]; + + // User permissions + perms.push(types[(mode >> 6) & 7]); + // Group permissions + perms.push(types[(mode >> 3) & 7]); + // Other permissions + perms.push(types[mode & 7]); + + return perms.join(""); +} diff --git a/builtin/renderer/mod.ts b/builtin/renderer/mod.ts index 8638262..921eaca 100644 --- a/builtin/renderer/mod.ts +++ b/builtin/renderer/mod.ts @@ -1,7 +1,10 @@ // This file is generated by gen-mod.ts export * from "./absolute_path.ts"; +export * from "./buffer_info.ts"; +export * from "./file_info.ts"; export * from "./helptag.ts"; export * from "./nerdfont.ts"; export * from "./noop.ts"; export * from "./relative_path.ts"; +export * from "./smart_grep.ts"; export * from "./smart_path.ts"; diff --git a/builtin/renderer/smart_grep.ts b/builtin/renderer/smart_grep.ts new file mode 100644 index 0000000..dc5c399 --- /dev/null +++ b/builtin/renderer/smart_grep.ts @@ -0,0 +1,205 @@ +import { defineRenderer, type Renderer } from "../../renderer.ts"; +import { dirname } from "@std/path/dirname"; + +type Detail = { + /** + * File path + */ + path: string; + + /** + * Line number + */ + line?: number; + + /** + * Column number + */ + column?: number; + + /** + * Matched text or line content + */ + text?: string; +}; + +export type SmartGrepOptions = { + /** + * Maximum length for displayed text. + * @default 80 + */ + maxTextLength?: number; + + /** + * Whether to show line and column numbers. + * @default true + */ + showLineNumbers?: boolean; + + /** + * Whether to group results by directory. + * @default false + */ + groupByDirectory?: boolean; + + /** + * Whether to use relative paths. + * @default true + */ + useRelativePaths?: boolean; + + /** + * Whether to colorize output (using ANSI codes). + * @default false + */ + colorize?: boolean; + + /** + * Whether to align columns. + * @default true + */ + alignColumns?: boolean; +}; + +/** + * Creates a Renderer that formats grep-like results in a smart way. + * + * This Renderer reformats items that contain file paths, line numbers, + * and matched text into a more readable format with proper alignment + * and optional grouping. + * + * @param options - Options to customize smart grep display. + * @returns A Renderer that reformats grep-like results. + */ +export function smartGrep( + options: Readonly = {}, +): Renderer { + const maxTextLength = options.maxTextLength ?? 80; + const showLineNumbers = options.showLineNumbers ?? true; + const groupByDirectory = options.groupByDirectory ?? false; + const useRelativePaths = options.useRelativePaths ?? true; + const colorize = options.colorize ?? false; + const alignColumns = options.alignColumns ?? true; + + return defineRenderer((_denops, { items }) => { + if (items.length === 0) { + return; + } + + // Calculate maximum widths for alignment + let maxPathWidth = 0; + let maxLineWidth = 0; + let maxColumnWidth = 0; + + if (alignColumns) { + for (const item of items) { + const { path, line, column } = item.detail; + const displayPath = useRelativePaths ? path : path; + maxPathWidth = Math.max(maxPathWidth, displayPath.length); + if (line !== undefined) { + maxLineWidth = Math.max(maxLineWidth, line.toString().length); + } + if (column !== undefined) { + maxColumnWidth = Math.max(maxColumnWidth, column.toString().length); + } + } + } + + // Group items by directory if requested + if (groupByDirectory) { + const groups = new Map(); + + for (const item of items) { + const dir = dirname(item.detail.path); + if (!groups.has(dir)) { + groups.set(dir, []); + } + groups.get(dir)!.push(item); + } + + // Process each group + let currentDir = ""; + for (const item of items) { + const dir = dirname(item.detail.path); + + // Add directory header if changed + if (dir !== currentDir) { + currentDir = dir; + // Modify the first item in each directory group to include header + const dirHeader = `=== ${dir} ===`; + if (colorize) { + item.label = `\x1b[36m${dirHeader}\x1b[0m\n${formatItem(item)}`; + } else { + item.label = `${dirHeader}\n${formatItem(item)}`; + } + } else { + item.label = formatItem(item); + } + } + } else { + // Format each item individually + for (const item of items) { + item.label = formatItem(item); + } + } + + function formatItem(item: typeof items[0]): string { + const { path, line, column, text } = item.detail; + const parts: string[] = []; + + // Format path + const displayPath = useRelativePaths ? path : path; + const pathPart = alignColumns + ? displayPath.padEnd(maxPathWidth) + : displayPath; + + if (colorize) { + parts.push(`\x1b[35m${pathPart}\x1b[0m`); // Magenta for path + } else { + parts.push(pathPart); + } + + // Format line and column numbers + if (showLineNumbers && line !== undefined) { + const lineStr = alignColumns + ? line.toString().padStart(maxLineWidth) + : line.toString(); + + if (column !== undefined) { + const colStr = alignColumns + ? column.toString().padStart(maxColumnWidth) + : column.toString(); + + if (colorize) { + parts.push(`\x1b[32m${lineStr}:${colStr}\x1b[0m`); // Green for numbers + } else { + parts.push(`${lineStr}:${colStr}`); + } + } else { + if (colorize) { + parts.push(`\x1b[32m${lineStr}\x1b[0m`); // Green for line number + } else { + parts.push(lineStr); + } + } + } + + // Format text + if (text) { + let displayText = text.trim(); + if (displayText.length > maxTextLength) { + displayText = displayText.substring(0, maxTextLength - 3) + "..."; + } + + if (colorize) { + // Try to highlight the matched part (simple approach) + parts.push(`\x1b[37m${displayText}\x1b[0m`); // White for text + } else { + parts.push(displayText); + } + } + + return parts.join(" "); + } + }); +} diff --git a/builtin/source/autocmd.ts b/builtin/source/autocmd.ts new file mode 100644 index 0000000..b1896b0 --- /dev/null +++ b/builtin/source/autocmd.ts @@ -0,0 +1,192 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Autocmd event name + */ + event: string; + + /** + * Autocmd group name (if any) + */ + group?: string; + + /** + * Pattern the autocmd matches + */ + pattern: string; + + /** + * Command to execute + */ + command: string; + + /** + * Whether it's a buffer-local autocmd + */ + bufferLocal: boolean; + + /** + * Additional autocmd flags/attributes + */ + flags: string[]; +}; + +export type AutocmdOptions = { + /** + * Filter by specific event(s). + */ + events?: string[]; + + /** + * Filter by autocmd group. + */ + group?: string; + + /** + * Whether to include buffer-local autocmds. + * @default true + */ + includeBufferLocal?: boolean; + + /** + * Whether to show detailed command (full command vs truncated). + * @default false + */ + showFullCommand?: boolean; +}; + +/** + * Creates a Source that generates items from Vim autocmds. + * + * This Source retrieves all defined autocmds and generates items + * for each one, showing their events, patterns, and commands. + * + * @param options - Options to customize autocmd listing. + * @returns A Source that generates items representing autocmds. + */ +// Regular expressions for parsing autocmd output +const PATTERNS = { + GROUP_HEADER: /^--- Autocommands ---$/, + NAMED_GROUP_HEADER: /^(\w+)\s+Autocommands for "(.+)"$/, + EVENT_HEADER: /^(\w+)$/, + AUTOCMD_LINE: /^\s*(\S+)\s+(.+)$/, +} as const; + +/** + * Creates a Source that generates items from Vim autocmds. + * + * This Source retrieves all defined autocmds and generates items + * for each one, showing their events, patterns, and commands. + * + * @param options - Options to customize autocmd listing. + * @returns A Source that generates items representing autocmds. + */ +export function autocmd( + options: Readonly = {}, +): Source { + const filterEvents = options.events; + const filterGroup = options.group; + const includeBufferLocal = options.includeBufferLocal ?? true; + const showFullCommand = options.showFullCommand ?? false; + + return defineSource(async function* (denops, _params, { signal }) { + // Get autocmd output + const autocmdCmd = filterGroup ? `autocmd ${filterGroup}` : "autocmd"; + const output = await fn.execute(denops, autocmdCmd); + signal?.throwIfAborted(); + + // Parse autocmd output + const lines = output.trim().split("\n"); + const items: Array<{ + id: number; + value: string; + detail: Detail; + }> = []; + + let id = 0; + let currentGroup = ""; + let currentEvent = ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Check for group header + if (PATTERNS.GROUP_HEADER.test(trimmed)) { + currentGroup = ""; + continue; + } + + // Check for named group header + const namedGroupMatch = trimmed.match(PATTERNS.NAMED_GROUP_HEADER); + if (namedGroupMatch) { + currentGroup = namedGroupMatch[1]; + continue; + } + + // Check for event header + const eventMatch = trimmed.match(PATTERNS.EVENT_HEADER); + if (eventMatch && !trimmed.includes(" ")) { + currentEvent = eventMatch[1]; + continue; + } + + // Parse autocmd line + // Format: " pattern command" + const cmdMatch = trimmed.match(PATTERNS.AUTOCMD_LINE); + if (cmdMatch && currentEvent) { + const [, pattern, command] = cmdMatch; + + // Filter by event if specified + if (filterEvents && !filterEvents.includes(currentEvent)) { + continue; + } + + // Parse flags from pattern + const flags: string[] = []; + let bufferLocal = false; + let cleanPattern = pattern; + + // Check for buffer-local indicator + if (pattern.startsWith("")) { + bufferLocal = true; + cleanPattern = pattern.replace("", "").trim(); + flags.push("buffer"); + } + + // Skip buffer-local if not included + if (bufferLocal && !includeBufferLocal) { + continue; + } + + // Format display value + const groupStr = currentGroup ? `[${currentGroup}] ` : ""; + const bufferStr = bufferLocal ? " " : ""; + const truncatedCmd = showFullCommand || command.length <= 50 + ? command + : command.substring(0, 47) + "..."; + + const value = + `${groupStr}${currentEvent} ${cleanPattern}${bufferStr} → ${truncatedCmd}`; + + items.push({ + id: id++, + value, + detail: { + event: currentEvent, + group: currentGroup || undefined, + pattern: cleanPattern, + command, + bufferLocal, + flags, + }, + }); + } + } + + yield* items; + }); +} diff --git a/builtin/source/colorscheme.ts b/builtin/source/colorscheme.ts new file mode 100644 index 0000000..37e8655 --- /dev/null +++ b/builtin/source/colorscheme.ts @@ -0,0 +1,79 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Colorscheme name + */ + name: string; + + /** + * Whether this is the current colorscheme + */ + current: boolean; +}; + +export type ColorschemeOptions = { + /** + * Whether to mark the current colorscheme. + * @default true + */ + markCurrent?: boolean; + + /** + * The indicator string for the current colorscheme. + * @default "> " + */ + indicator?: string; +}; + +/** + * Creates a Source that generates items from available Vim colorschemes. + * + * This Source retrieves all available colorschemes and generates items + * for each one, optionally marking the currently active colorscheme. + * + * @param options - Options to customize colorscheme listing. + * @returns A Source that generates items representing colorschemes. + */ +export function colorscheme( + options: Readonly = {}, +): Source { + const markCurrent = options.markCurrent ?? true; + return defineSource(async function* (denops, _params, { signal }) { + // Get list of all colorschemes + const colorschemes = await fn.getcompletion( + denops, + "", + "color", + ) as string[]; + signal?.throwIfAborted(); + + // Get current colorscheme if needed + let currentColorscheme = ""; + if (markCurrent) { + const colors = await fn.execute(denops, "colorscheme") as string; + // Extract colorscheme name from output (removes whitespace and newlines) + currentColorscheme = colors.trim(); + } + + const items = colorschemes.map((name, index) => { + const isCurrent = markCurrent && name === currentColorscheme; + const indicator = options.indicator ?? "> "; + const prefix = isCurrent ? indicator : " ".repeat(indicator.length); + const suffix = isCurrent ? " (current)" : ""; + + return { + id: index, + value: `${prefix}${name}${suffix}`, + detail: { + name, + current: isCurrent, + }, + }; + }); + + yield* items; + }); +} diff --git a/builtin/source/command.ts b/builtin/source/command.ts new file mode 100644 index 0000000..f625f73 --- /dev/null +++ b/builtin/source/command.ts @@ -0,0 +1,178 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Command name + */ + name: string; + + /** + * Command definition/replacement text + */ + definition: string; + + /** + * Command attributes (bang, range, etc.) + */ + attributes: string; + + /** + * Whether the command is buffer-local + */ + bufferLocal: boolean; + + /** + * Number of arguments the command accepts + */ + nargs: string; + + /** + * Completion type + */ + complete?: string; +}; + +export type CommandOptions = { + /** + * Whether to include buffer-local commands. + * @default true + */ + includeBufferLocal?: boolean; + + /** + * Whether to include builtin commands. + * @default false + */ + includeBuiltin?: boolean; +}; + +/** + * Creates a Source that generates items from user-defined Vim commands. + * + * This Source retrieves all user-defined commands and generates items + * for each one, showing their definition and attributes. + * + * @param options - Options to customize command listing. + * @returns A Source that generates items representing commands. + */ +export function command( + options: Readonly = {}, +): Source { + const includeBufferLocal = options.includeBufferLocal ?? true; + const includeBuiltin = options.includeBuiltin ?? false; + + return defineSource(async function* (denops, _params, { signal }) { + // Get user commands + const commandOutput = await fn.execute(denops, "command"); + signal?.throwIfAborted(); + + // Parse command output + const lines = commandOutput.trim().split("\n").filter((line) => + line.trim() + ); + const items: Array<{ + id: number; + value: string; + detail: Detail; + }> = []; + + let id = 0; + for (const line of lines) { + // Skip header line + if (line.includes("Name") && line.includes("Args")) { + continue; + } + + // Parse command line + // Format: " Name Args Address Complete Definition" + const match = line.match( + /^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S*)\s+(.*)$/, + ); + if (!match) { + continue; + } + + const [, name, nargs, address, complete, definition] = match; + + // Check if it's a buffer-local command + const bufferLocal = name.startsWith("b:"); + + // Skip buffer-local commands if not included + if (bufferLocal && !includeBufferLocal) { + continue; + } + + // Skip builtin commands if not included (they start with uppercase) + if (!includeBuiltin && /^[A-Z]/.test(name) && !name.includes(":")) { + continue; + } + + // Build attributes string + const attributes: string[] = []; + if (nargs !== "0") { + attributes.push(`nargs=${nargs}`); + } + if (address !== ".") { + attributes.push(`addr=${address}`); + } + if (complete) { + attributes.push(`complete=${complete}`); + } + + // Format display value + const attrStr = attributes.length > 0 + ? ` (${attributes.join(", ")})` + : ""; + const localStr = bufferLocal ? " [buffer]" : ""; + const truncatedDef = definition.length > 50 + ? definition.substring(0, 47) + "..." + : definition; + + items.push({ + id: id++, + value: `:${name}${localStr}${attrStr} → ${truncatedDef}`, + detail: { + name, + definition, + attributes: attrStr, + bufferLocal, + nargs, + complete: complete || undefined, + }, + }); + } + + // Get completion list if needed + if (includeBuiltin) { + const builtinCommands = await fn.getcompletion( + denops, + "", + "command", + ) as string[]; + + for (const cmd of builtinCommands) { + // Skip if already in the list + if (items.some((item) => item.detail.name === cmd)) { + continue; + } + + items.push({ + id: id++, + value: `:${cmd} [builtin]`, + detail: { + name: cmd, + definition: "(builtin command)", + attributes: "", + bufferLocal: false, + nargs: "?", + complete: undefined, + }, + }); + } + } + + yield* items; + }); +} diff --git a/builtin/source/git_status.ts b/builtin/source/git_status.ts new file mode 100644 index 0000000..68f1f79 --- /dev/null +++ b/builtin/source/git_status.ts @@ -0,0 +1,213 @@ +import * as fn from "@denops/std/function"; +import { join } from "@std/path/join"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * File path relative to git root + */ + path: string; + + /** + * Absolute file path + */ + absolutePath: string; + + /** + * Git status code (e.g., "M", "A", "D", "??") + */ + status: string; + + /** + * Human-readable status description + */ + statusDescription: string; + + /** + * Whether the file is staged + */ + staged: boolean; + + /** + * Whether the file is unstaged + */ + unstaged: boolean; +}; + +export type GitStatusOptions = { + /** + * Whether to include untracked files. + * @default true + */ + includeUntracked?: boolean; + + /** + * Whether to include ignored files. + * @default false + */ + includeIgnored?: boolean; + + /** + * Whether to show status in submodules. + * @default false + */ + includeSubmodules?: boolean; +}; + +// Git status format codes +const STATUS_CODES = { + STAGED: { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + }, + UNSTAGED: { + M: "modified", + D: "deleted", + }, + UNTRACKED: "??", + IGNORED: "!!", +} as const; + +/** + * Creates a Source that generates items from git status. + * + * This Source runs `git status` and generates items for each modified, + * staged, or untracked file in the repository. + * + * @param options - Options to customize git status listing. + * @returns A Source that generates items representing git status files. + */ +export function gitStatus( + options: Readonly = {}, +): Source { + const includeUntracked = options.includeUntracked ?? true; + const includeIgnored = options.includeIgnored ?? false; + const includeSubmodules = options.includeSubmodules ?? false; + + return defineSource(async function* (denops, _params, { signal }) { + // Get current working directory + const cwd = await fn.getcwd(denops); + signal?.throwIfAborted(); + + // Build git status command + const args = ["status", "--porcelain=v1"]; + if (includeUntracked) { + args.push("-u"); + } else { + args.push("-uno"); + } + if (includeIgnored) { + args.push("--ignored"); + } + if (!includeSubmodules) { + args.push("--ignore-submodules"); + } + + try { + // Run git status + const cmd = new Deno.Command("git", { + args, + cwd, + stdout: "piped", + stderr: "piped", + signal, + }); + + const { stdout, stderr, success } = await cmd.output(); + + if (!success) { + // Not a git repository or git command failed + const errorText = new TextDecoder().decode(stderr); + if (errorText.includes("not a git repository")) { + // Silently return empty - not an error condition + return; + } + throw new Error(`git status failed: ${errorText}`); + } + + // Parse git status output + const output = new TextDecoder().decode(stdout); + const lines = output.trim().split("\n").filter((line) => line); + + const items = lines.map((line, index) => { + // Git status format: XY filename + // X = staged status, Y = unstaged status + const staged = line[0]; + const unstaged = line[1]; + const filename = line.substring(3); + + // Determine status code and description + const status = `${staged}${unstaged}`; + let statusDescription = ""; + let isStaged = false; + let isUnstaged = false; + + // Parse status codes + if (status === STATUS_CODES.UNTRACKED) { + statusDescription = "untracked"; + isUnstaged = true; + } else if (status === STATUS_CODES.IGNORED) { + statusDescription = "ignored"; + } else { + // Handle staged status + const stagedDesc = + STATUS_CODES.STAGED[staged as keyof typeof STATUS_CODES.STAGED]; + if (stagedDesc) { + statusDescription = stagedDesc; + isStaged = true; + } + + // Handle unstaged status + const unstagedDesc = STATUS_CODES + .UNSTAGED[unstaged as keyof typeof STATUS_CODES.UNSTAGED]; + if (unstagedDesc) { + statusDescription += isStaged ? `, ${unstagedDesc}` : unstagedDesc; + isUnstaged = true; + } + } + + // Create status indicator + const indicator = status === STATUS_CODES.UNTRACKED + ? "[?]" + : status === STATUS_CODES.IGNORED + ? "[!]" + : `[${status}]`; + + // Format display value + const absolutePath = join(cwd, filename); + const displayPath = filename; + const value = `${indicator.padEnd(5)} ${displayPath}`; + + return { + id: index, + value, + detail: { + path: filename, + absolutePath, + status, + statusDescription, + staged: isStaged, + unstaged: isUnstaged, + }, + }; + }); + + yield* items; + } catch (err) { + // Handle errors gracefully + if (err instanceof Error) { + if (err.name === "NotFound") { + // Git not installed - silently return empty + return; + } + // Re-throw other errors with context + throw new Error(`Failed to get git status: ${err.message}`); + } + throw err; + } + }); +} diff --git a/builtin/source/grep.ts b/builtin/source/grep.ts new file mode 100644 index 0000000..eb97396 --- /dev/null +++ b/builtin/source/grep.ts @@ -0,0 +1,194 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * File path + */ + path: string; + + /** + * Line number + */ + line: number; + + /** + * Column number + */ + column: number; + + /** + * Matched text + */ + text: string; + + /** + * The pattern that was searched + */ + pattern: string; +}; + +export type GrepOptions = { + /** + * The pattern to search for. + * If not provided, uses the last search pattern. + */ + pattern?: string; + + /** + * Files to search in. + * Defaults to current file if not specified. + */ + files?: string[]; + + /** + * Additional grep flags. + */ + flags?: string; + + /** + * Whether to use the quickfix list. + * If false, returns results directly without populating quickfix. + * @default true + */ + useQuickfix?: boolean; +}; + +/** + * Creates a Source that generates items from Vim's :grep command results. + * + * This Source executes Vim's :grep command and generates items from the results. + * It uses the 'grepprg' and 'grepformat' settings to parse the output. + * + * @param options - Options to customize grep execution. + * @returns A Source that generates items representing grep results. + */ +export function grep( + options: Readonly = {}, +): Source { + const pattern = options.pattern; + const files = options.files ?? ["%"]; + const flags = options.flags ?? ""; + const useQuickfix = options.useQuickfix ?? true; + + return defineSource(async function* (denops, _params, { signal }) { + // Get the pattern to search for + let searchPattern = pattern; + if (!searchPattern) { + // Use last search pattern + const regValue = await fn.getreg(denops, "/"); + searchPattern = typeof regValue === "string" ? regValue : ""; + if (!searchPattern) { + return; + } + } + + signal?.throwIfAborted(); + + // Build grep command + const args = [searchPattern, ...files]; + const grepCmd = flags + ? `:grep! ${flags} ${ + args.map((a) => `'${a.replace(/'/g, "''")}'`).join(" ") + }` + : `:grep! ${args.map((a) => `'${a.replace(/'/g, "''")}'`).join(" ")}`; + + try { + if (useQuickfix) { + // Execute grep command + await denops.cmd(`silent! ${grepCmd}`); + signal?.throwIfAborted(); + + // Get results from quickfix list + const qflist = await fn.getqflist(denops) as unknown as Array<{ + bufnr: number; + lnum: number; + col: number; + text: string; + valid: number; + }>; + + // Clear quickfix list to avoid side effects + await denops.cmd("cexpr []"); + + let id = 0; + for (const item of qflist) { + if (!item.valid) { + continue; + } + + // Get filename from buffer number + let filename = ""; + if (item.bufnr > 0) { + filename = await fn.bufname(denops, item.bufnr); + } + + if (!filename) { + continue; + } + + // Format display value + const locationStr = `${filename}:${item.lnum}:${item.col}`; + const value = `${locationStr}: ${item.text}`; + + yield { + id: id++, + value, + detail: { + path: filename, + line: item.lnum, + column: item.col, + text: item.text, + pattern: searchPattern, + }, + }; + } + } else { + // Execute grep directly and parse output + const grepprg = await denops.eval("&grepprg"); + + // This is a simplified implementation + // In practice, we'd need to properly parse grepformat + // For now, assume standard grep output format + const output = await fn.system( + denops, + `${grepprg} ${args.join(" ")}`, + ); + + if (output) { + const lines = output.trim().split("\n"); + let id = 0; + + for (const line of lines) { + // Simple parsing for "filename:line:column:text" format + const match = line.match(/^([^:]+):(\d+):(\d+)?:?(.*)$/); + if (match) { + const [, filename, lineStr, colStr, text] = match; + const lineNum = parseInt(lineStr, 10); + const colNum = colStr ? parseInt(colStr, 10) : 1; + + yield { + id: id++, + value: line, + detail: { + path: filename, + line: lineNum, + column: colNum, + text: text.trim(), + pattern: searchPattern, + }, + }; + } + } + } + } + } catch (error) { + // Grep might return non-zero exit code if no matches found + // This is not an error condition + if (error instanceof Error && !error.message.includes("E480")) { + throw error; + } + } + }); +} diff --git a/builtin/source/highlight.ts b/builtin/source/highlight.ts new file mode 100644 index 0000000..4b7a428 --- /dev/null +++ b/builtin/source/highlight.ts @@ -0,0 +1,101 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Highlight group name + */ + name: string; + + /** + * Whether the highlight group is linked + */ + linked: boolean; + + /** + * The target group if linked + */ + linkTarget?: string; + + /** + * Whether the highlight group is cleared + */ + cleared: boolean; +}; + +export type HighlightOptions = { + /** + * Whether to include cleared highlight groups. + * @default false + */ + includeCleared?: boolean; +}; + +/** + * Creates a Source that generates items from Vim highlight groups. + * + * This Source retrieves all highlight groups and generates items + * for each one, showing their definition status and link targets. + * + * @param options - Options to customize highlight group listing. + * @returns A Source that generates items representing highlight groups. + */ +export function highlight( + options: Readonly = {}, +): Source { + const includeCleared = options.includeCleared ?? false; + return defineSource(async function* (denops, _params, { signal }) { + // Get list of all highlight groups + const highlightGroups = await fn.getcompletion( + denops, + "", + "highlight", + ) as string[]; + signal?.throwIfAborted(); + + // Get detailed information about each highlight group + const items = []; + let index = 0; + for (const name of highlightGroups) { + // Execute highlight command to get details + const output = await fn.execute( + denops, + `highlight ${name}`, + ) as string; + + // Parse the output to determine status + const trimmed = output.trim(); + const cleared = trimmed.includes("xxx cleared"); + const linkMatch = trimmed.match(/xxx links to (\S+)/); + const linked = !!linkMatch; + const linkTarget = linkMatch?.[1]; + + // Skip cleared groups if not included + if (cleared && !includeCleared) { + continue; + } + + // Format the display value + let value = name; + if (linked && linkTarget) { + value += ` → ${linkTarget}`; + } else if (cleared) { + value += " (cleared)"; + } + + items.push({ + id: index++, + value, + detail: { + name, + linked, + linkTarget, + cleared, + }, + }); + } + + yield* items; + }); +} diff --git a/builtin/source/jumplist.ts b/builtin/source/jumplist.ts new file mode 100644 index 0000000..d5c7777 --- /dev/null +++ b/builtin/source/jumplist.ts @@ -0,0 +1,122 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Jump number (position in jumplist) + */ + jump: number; + + /** + * Line number + */ + line: number; + + /** + * Column number + */ + column: number; + + /** + * Buffer number + */ + bufnr: number; + + /** + * Buffer name/path + */ + bufname: string; + + /** + * File content at the jump location + */ + text: string; +}; + +/** + * Creates a Source that generates items from Vim's jumplist. + * + * This Source retrieves the jumplist entries and generates items + * for each jump location with file and position information. + * + * @returns A Source that generates items representing jump locations. + */ +/** + * Parses jump list entry to create formatted item. + */ +function formatJumpItem( + jump: JumplistItem, + index: number, + currentPos: number, + bufname: string, + text: string, +): { id: number; value: string; detail: Detail } { + const jumpNum = index - currentPos; + const jumpIndicator = jumpNum === 0 + ? ">" + : jumpNum < 0 + ? jumpNum.toString() + : `+${jumpNum}`; + const displayName = bufname || "[No Name]"; + const shortName = displayName.split("/").pop() || displayName; + + return { + id: index, + value: `${ + jumpIndicator.padStart(4) + } ${shortName}:${jump.lnum}:${jump.col} ${text.trim()}`, + detail: { + jump: jumpNum, + line: jump.lnum, + column: jump.col, + bufnr: jump.bufnr, + bufname: bufname, + text: text, + }, + }; +} + +export function jumplist(): Source { + return defineSource(async function* (denops, _params, { signal }) { + // Get jumplist + const jumplistData = await fn.getjumplist(denops) as [ + JumplistItem[], + number, + ]; + const [jumplist, currentPos] = jumplistData; + signal?.throwIfAborted(); + + // Process each jump entry + let index = 0; + for (const jump of jumplist) { + // Get buffer name + const bufname = jump.bufnr > 0 + ? await fn.bufname(denops, jump.bufnr) + : ""; + + // Try to get the line content if buffer is loaded + let text = ""; + if (jump.bufnr > 0 && await fn.bufloaded(denops, jump.bufnr)) { + const lines = await fn.getbufline( + denops, + jump.bufnr, + jump.lnum, + jump.lnum, + ); + text = lines[0] || ""; + } + + yield formatJumpItem(jump, index, currentPos, bufname, text); + index++; + } + }); +} + +type JumplistItem = { + bufnr: number; + col: number; + coladd: number; + filename: string; + lnum: number; +}; diff --git a/builtin/source/loclist.ts b/builtin/source/loclist.ts new file mode 100644 index 0000000..8481b68 --- /dev/null +++ b/builtin/source/loclist.ts @@ -0,0 +1,121 @@ +import { enumerate } from "@core/iterutil/enumerate"; +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Window ID + */ + winid: number; + + /** + * Buffer number + */ + bufnr: number; + + /** + * Line number + */ + line: number; + + /** + * Column number + */ + column: number; + + /** + * Location list item + */ + loclist: LoclistItem; +}; + +export type LoclistOptions = { + /** + * Whether to include location lists from all windows. + * If false, only the current window's location list is included. + * @default false + */ + allWindows?: boolean; +}; + +/** + * Provides a source for location list items. + * + * This source reads the location list from the current window or all windows + * and yields each item with detailed information. + * + * @param options - Options to customize location list retrieval. + * @returns A source that yields location list items. + */ +export function loclist( + options: Readonly = {}, +): Source { + const allWindows = options.allWindows ?? false; + return defineSource(async function* (denops, _params, { signal }) { + let winids: number[]; + + if (allWindows) { + // Get all window IDs + const wininfos = await fn.getwininfo(denops); + winids = wininfos.map((w) => w.winid); + } else { + // Get current window ID + const currentWinid = await fn.win_getid(denops); + winids = [currentWinid]; + } + + signal?.throwIfAborted(); + + let globalId = 0; + for (const winid of winids) { + // Get location list for this window + const loclistItems = await fn.getloclist( + denops, + winid, + ) as unknown as LoclistItem[]; + + if (loclistItems.length === 0) { + continue; + } + + // Get window number for display + const winnr = await fn.win_id2win(denops, winid); + const winPrefix = allWindows ? `[Win ${winnr}] ` : ""; + + for (const [_index, item] of enumerate(loclistItems)) { + const length = (item.end_col ?? 0) - item.col; + const decorations = length > 0 ? [{ column: item.col, length }] : []; + + yield { + id: globalId++, + value: `${winPrefix}${item.text}`, + detail: { + winid, + bufnr: item.bufnr, + line: item.lnum, + column: item.col, + loclist: item, + }, + decorations, + }; + } + } + }); +} + +type LoclistItem = { + bufnr: number; + module: string; + lnum: number; + end_lnum?: number; + col: number; + end_col?: number; + vcol: boolean; + nr: number; + pattern: string; + text: string; + type: string; + valid: boolean; + user_data: unknown; +}; diff --git a/builtin/source/mapping.ts b/builtin/source/mapping.ts new file mode 100644 index 0000000..3bc2628 --- /dev/null +++ b/builtin/source/mapping.ts @@ -0,0 +1,166 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Mapping key/lhs + */ + lhs: string; + + /** + * Mapping value/rhs + */ + rhs: string; + + /** + * Mapping mode + */ + mode: string; + + /** + * Whether the mapping is buffer-local + */ + bufferLocal: boolean; + + /** + * Whether the mapping is silent + */ + silent: boolean; + + /** + * Whether the mapping is noremap + */ + noremap: boolean; + + /** + * Whether the mapping waits for more input + */ + nowait: boolean; + + /** + * Whether the mapping is an expression + */ + expr: boolean; +}; + +export type MappingOptions = { + /** + * Which modes to include mappings from. + * Default includes all common modes. + * @default ["n", "i", "v", "x", "s", "o", "c", "t"] + */ + modes?: string[]; + + /** + * Whether to include buffer-local mappings. + * @default true + */ + includeBufferLocal?: boolean; + + /** + * Whether to include plugin mappings (mappings containing ). + * @default false + */ + includePluginMappings?: boolean; +}; + +/** + * Creates a Source that generates items from Vim key mappings. + * + * This Source retrieves all key mappings from specified modes and + * generates items for each one, showing their definitions and attributes. + * + * @param options - Options to customize mapping listing. + * @returns A Source that generates items representing mappings. + */ +export function mapping( + options: Readonly = {}, +): Source { + const modes = options.modes ?? ["n", "i", "v", "x", "s", "o", "c", "t"]; + const includeBufferLocal = options.includeBufferLocal ?? true; + const includePluginMappings = options.includePluginMappings ?? false; + + return defineSource(async function* (denops, _params, { signal }) { + const items: Array<{ + id: number; + value: string; + detail: Detail; + }> = []; + + let id = 0; + for (const mode of modes) { + // Get mappings for this mode + const mappingList = await fn.maplist(denops, mode) as Array<{ + lhs: string; + rhs: string; + silent: number | boolean; + noremap: number | boolean; + nowait: number | boolean; + expr: number | boolean; + buffer: number | boolean; + mode?: string; + sid?: number; + lnum?: number; + script?: number | boolean; + }>; + signal?.throwIfAborted(); + + for (const mapping of mappingList) { + // Skip buffer-local mappings if not included + const isBufferLocal = Boolean(mapping.buffer); + if (isBufferLocal && !includeBufferLocal) { + continue; + } + + // Skip plugin mappings if not included + if (!includePluginMappings && mapping.rhs.includes("")) { + continue; + } + + // Build attributes + const attributes: string[] = []; + if (mapping.silent) attributes.push("silent"); + if (mapping.noremap) attributes.push("noremap"); + if (mapping.nowait) attributes.push("nowait"); + if (mapping.expr) attributes.push("expr"); + if (isBufferLocal) attributes.push("buffer"); + + // Format mode indicator + const modeIndicator = mode === "n" ? " " : `[${mode}]`; + + // Format display value + const attrStr = attributes.length > 0 + ? ` (${attributes.join(", ")})` + : ""; + const truncatedRhs = mapping.rhs.length > 50 + ? mapping.rhs.substring(0, 47) + "..." + : mapping.rhs; + + items.push({ + id: id++, + value: `${modeIndicator} ${mapping.lhs}${attrStr} → ${truncatedRhs}`, + detail: { + lhs: mapping.lhs, + rhs: mapping.rhs, + mode: mode, + bufferLocal: isBufferLocal, + silent: Boolean(mapping.silent), + noremap: Boolean(mapping.noremap), + nowait: Boolean(mapping.nowait), + expr: Boolean(mapping.expr), + }, + }); + } + } + + // Sort by mode and then by lhs + items.sort((a, b) => { + const modeCmp = a.detail.mode.localeCompare(b.detail.mode); + if (modeCmp !== 0) return modeCmp; + return a.detail.lhs.localeCompare(b.detail.lhs); + }); + + yield* items; + }); +} diff --git a/builtin/source/mark.ts b/builtin/source/mark.ts new file mode 100644 index 0000000..8c6b0ac --- /dev/null +++ b/builtin/source/mark.ts @@ -0,0 +1,191 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +// Mark categories +const LOCAL_MARKS = "abcdefghijklmnopqrstuvwxyz".split(""); +const GLOBAL_MARKS = [ + ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""), + ..."0123456789".split(""), +]; +const SPECIAL_MARKS = [ + ".", // last change + "^", // last insert + "<", // start of last visual selection + ">", // end of last visual selection + "'", // previous context mark + '"', // position when last exiting buffer + "[", // start of last change/yank + "]", // end of last change/yank +]; + +// Special mark descriptions +const MARK_DESCRIPTIONS: Record = { + ".": "last change", + "^": "last insert", + "<": "visual start", + ">": "visual end", + "'": "previous context", + '"': "last exit", + "[": "change/yank start", + "]": "change/yank end", +}; + +type Detail = { + /** + * Mark name + */ + mark: string; + + /** + * Line number + */ + line: number; + + /** + * Column number + */ + column: number; + + /** + * Buffer number (0 for global marks) + */ + bufnr: number; + + /** + * File path (for global marks) + */ + file: string; +}; + +export type MarkOptions = { + /** + * Whether to include global marks (A-Z, 0-9). + * @default true + */ + includeGlobal?: boolean; + + /** + * Whether to include local marks (a-z). + * @default true + */ + includeLocal?: boolean; + + /** + * Whether to include special marks (. ^ < > etc). + * @default true + */ + includeSpecial?: boolean; +}; + +/** + * Creates a Source that generates items from Vim marks. + * + * This Source retrieves all marks and generates items for each one, + * showing their location and type (local, global, or special). + * + * @param options - Options to customize mark listing. + * @returns A Source that generates items representing marks. + */ +export function mark(options: Readonly = {}): Source { + const includeGlobal = options.includeGlobal ?? true; + const includeLocal = options.includeLocal ?? true; + const includeSpecial = options.includeSpecial ?? true; + + return defineSource(async function* (denops, _params, { signal }) { + const marks: string[] = []; + + // Add marks based on options + if (includeLocal) { + marks.push(...LOCAL_MARKS); + } + if (includeGlobal) { + marks.push(...GLOBAL_MARKS); + } + if (includeSpecial) { + marks.push(...SPECIAL_MARKS); + } + + signal?.throwIfAborted(); + + // Get all marks information if looking for global marks + let globalMarkInfo: Array<{ + mark: string; + pos: [number, number, number, number]; + file: string; + }> = []; + if (includeGlobal) { + globalMarkInfo = await fn.execute( + denops, + "marks", + ) as unknown as typeof globalMarkInfo; + } + + const items = []; + let index = 0; + for (const markName of marks) { + // Get mark position + const pos = await fn.getpos(denops, `'${markName}`) as [ + number, + number, + number, + number, + ]; + const [bufnr, line, col] = pos; + + // Skip marks that don't exist (line 0) + if (line === 0) { + index++; + continue; + } + + // Get file path for the mark + let file = ""; + let displayName = ""; + if (bufnr === 0) { + // Global mark - has file path + const globalMark = globalMarkInfo.find((m) => + m.mark === `'${markName}` || m.mark === markName + ); + if (globalMark) { + file = globalMark.file; + displayName = file.split("/").pop() || file; + } + } else { + // Local mark - get buffer name + displayName = await fn.bufname(denops, bufnr) || "[No Name]"; + displayName = displayName.split("/").pop() || displayName; + } + + // Get mark type + let markType = ""; + if (markName >= "a" && markName <= "z") { + markType = "[local]"; + } else if (markName >= "A" && markName <= "Z") { + markType = "[global]"; + } else if (markName >= "0" && markName <= "9") { + markType = "[numbered]"; + } else { + markType = "[special]"; + } + + const desc = MARK_DESCRIPTIONS[markName] + ? ` (${MARK_DESCRIPTIONS[markName]})` + : ""; + + items.push({ + id: index++, + value: `'${markName}${desc} ${markType} ${displayName}:${line}:${col}`, + detail: { + mark: markName, + line, + column: col, + bufnr, + file, + }, + }); + } + + yield* items; + }); +} diff --git a/builtin/source/mod.ts b/builtin/source/mod.ts index 0d14b6a..f1815d3 100644 --- a/builtin/source/mod.ts +++ b/builtin/source/mod.ts @@ -1,10 +1,24 @@ // This file is generated by gen-mod.ts +export * from "./autocmd.ts"; export * from "./buffer.ts"; +export * from "./colorscheme.ts"; +export * from "./command.ts"; export * from "./file.ts"; +export * from "./git_status.ts"; +export * from "./grep.ts"; export * from "./helptag.ts"; +export * from "./highlight.ts"; export * from "./history.ts"; +export * from "./jumplist.ts"; export * from "./line.ts"; export * from "./list.ts"; +export * from "./loclist.ts"; +export * from "./mapping.ts"; +export * from "./mark.ts"; export * from "./noop.ts"; export * from "./oldfiles.ts"; export * from "./quickfix.ts"; +export * from "./register.ts"; +export * from "./tabpage.ts"; +export * from "./vimgrep.ts"; +export * from "./window.ts"; diff --git a/builtin/source/register.ts b/builtin/source/register.ts new file mode 100644 index 0000000..b4ed316 --- /dev/null +++ b/builtin/source/register.ts @@ -0,0 +1,146 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +// Define all standard registers +const REGISTER_NAMES = [ + // Named registers + '"', // unnamed register + "-", // small delete register + "*", // clipboard (selection) + "+", // clipboard + // Numbered registers + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + // Named registers a-z + ..."abcdefghijklmnopqrstuvwxyz".split(""), + // Read-only registers + ".", // last inserted text + "%", // current file name + "#", // alternate file name + ":", // last command-line + "/", // last search pattern + "=", // expression register +] as const; + +// Special register descriptions +const REGISTER_DESCRIPTIONS: Record = { + '"': "unnamed", + "-": "small delete", + "*": "selection", + "+": "clipboard", + ".": "last inserted", + "%": "current file", + "#": "alternate file", + ":": "last command", + "/": "last search", + "=": "expression", +} as const; + +type Detail = { + /** + * Register name + */ + name: string; + + /** + * Register content + */ + content: string; + + /** + * Register type (linewise, characterwise, or blockwise) + */ + regtype: string; +}; + +export type RegisterOptions = { + /** + * Whether to include empty registers. + * @default false + */ + includeEmpty?: boolean; + + /** + * Maximum length of content to display in the value. + * @default 80 + */ + maxLength?: number; +}; + +/** + * Creates a Source that generates items from Vim registers. + * + * This Source retrieves all register contents and generates items + * for each register, showing their type and content. + * + * @param options - Options to customize register listing. + * @returns A Source that generates items representing registers. + */ +export function register( + options: Readonly = {}, +): Source { + const includeEmpty = options.includeEmpty ?? false; + const maxLength = options.maxLength ?? 80; + + return defineSource(async function* (denops, _params, { signal }) { + signal?.throwIfAborted(); + + const items = []; + let index = 0; + for (const reg of REGISTER_NAMES) { + // Get register content and type + const content = await fn.getreg(denops, reg) as string; + const regtype = await fn.getregtype(denops, reg) as string; + + // Skip empty registers if not included + if (!content && !includeEmpty) { + index++; + continue; + } + + // Format content for display + let displayContent = content || "(empty)"; + // Replace newlines with visible indicator + displayContent = displayContent.replace(/\n/g, "↵"); + // Truncate if too long + if (displayContent.length > maxLength) { + displayContent = displayContent.substring(0, maxLength - 3) + "..."; + } + + // Format register type indicator + let typeIndicator = ""; + if (regtype === "v") { + typeIndicator = "[c]"; // characterwise + } else if (regtype === "V") { + typeIndicator = "[l]"; // linewise + } else if (regtype.startsWith("\x16")) { + typeIndicator = "[b]"; // blockwise + } + + const desc = REGISTER_DESCRIPTIONS[reg] + ? ` (${REGISTER_DESCRIPTIONS[reg]})` + : ""; + + items.push({ + id: index++, + value: `"${reg}${desc} ${typeIndicator} ${displayContent}`, + detail: { + name: reg, + content: content, + regtype: regtype, + }, + }); + } + + yield* items; + }); +} diff --git a/builtin/source/tabpage.ts b/builtin/source/tabpage.ts new file mode 100644 index 0000000..2b8cf7b --- /dev/null +++ b/builtin/source/tabpage.ts @@ -0,0 +1,119 @@ +import * as fn from "@denops/std/function"; +import { collect } from "@denops/std/batch"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Tab page number (1-indexed) + */ + tabnr: number; + + /** + * Tab page variables + */ + variables: Record; + + /** + * Windows in the tab page + */ + windows: fn.WinInfo[]; +}; + +export type TabpageOptions = { + /** + * Whether to include the tab label. + * If true, uses the tab label if set, otherwise shows buffer names. + * @default true + */ + showLabel?: boolean; + + /** + * The indicator string for the current tab. + * @default "> " + */ + indicator?: string; +}; + +/** + * Creates a Source that generates items from Vim tab pages. + * + * This Source retrieves information about all tab pages and generates items + * for each tab with details about windows and buffers in that tab. + * + * @param options - Options to customize tabpage listing. + * @returns A Source that generates items representing tab pages. + */ +export function tabpage( + options: Readonly = {}, +): Source { + const showLabel = options.showLabel ?? true; + return defineSource(async function* (denops, _params, { signal }) { + const [currentTabnr, lastTabnr, allWininfos] = await collect( + denops, + (denops) => [ + fn.tabpagenr(denops), + fn.tabpagenr(denops, "$"), + fn.getwininfo(denops), + ], + ); + signal?.throwIfAborted(); + + const items = []; + for (let tabnr = 1; tabnr <= lastTabnr; tabnr++) { + // Get windows in this tab + const windows = allWininfos.filter((w) => w.tabnr === tabnr); + const windowCount = windows.length; + + // Get tab variables + const variables = await fn.gettabvar(denops, tabnr, "") as Record< + string, + unknown + >; + + let label: string; + if (showLabel) { + // Try to get tab label from 't:tabLabel' variable + const customLabel = await fn.gettabvar(denops, tabnr, "tabLabel") as + | string + | null; + if (customLabel) { + label = customLabel; + } else { + // Create label from buffer names in the tab + const bufferNames = []; + for (const w of windows) { + const name = await fn.bufname(denops, w.bufnr); + bufferNames.push( + name ? name.split("/").pop() || name : "[No Name]", + ); + } + label = bufferNames.join(", "); + } + } else { + label = `Tab ${tabnr}`; + } + + // Add current tab indicator + const indicator = options.indicator ?? "> "; + const prefix = tabnr === currentTabnr + ? indicator + : " ".repeat(indicator.length); + const value = `${prefix}${tabnr}: ${label} (${windowCount} window${ + windowCount !== 1 ? "s" : "" + })`; + + items.push({ + id: tabnr - 1, + value, + detail: { + tabnr, + variables, + windows, + }, + }); + } + + yield* items; + }); +} diff --git a/builtin/source/vimgrep.ts b/builtin/source/vimgrep.ts new file mode 100644 index 0000000..5dce2de --- /dev/null +++ b/builtin/source/vimgrep.ts @@ -0,0 +1,211 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * File path + */ + path: string; + + /** + * Line number + */ + line: number; + + /** + * Column number + */ + column: number; + + /** + * Matched text + */ + text: string; + + /** + * The pattern that was searched + */ + pattern: string; +}; + +export type VimgrepOptions = { + /** + * The pattern to search for. + * If not provided, uses the last search pattern. + */ + pattern?: string; + + /** + * Files to search in. + * Defaults to current file if not specified. + */ + files?: string[]; + + /** + * Vimgrep flags. + * g: all matches in a line + * j: don't jump to first match + * @default "j" + */ + flags?: string; + + /** + * Whether to use the quickfix list. + * If false, returns results directly without populating quickfix. + * @default true + */ + useQuickfix?: boolean; +}; + +/** + * Creates a Source that generates items from Vim's :vimgrep command results. + * + * This Source executes Vim's :vimgrep command and generates items from the results. + * Unlike :grep, :vimgrep uses Vim's internal pattern matching. + * + * @param options - Options to customize vimgrep execution. + * @returns A Source that generates items representing vimgrep results. + */ +export function vimgrep( + options: Readonly = {}, +): Source { + const pattern = options.pattern; + const files = options.files ?? ["%"]; + const flags = options.flags ?? "j"; + const useQuickfix = options.useQuickfix ?? true; + + return defineSource(async function* (denops, _params, { signal }) { + // Get the pattern to search for + let searchPattern = pattern; + if (!searchPattern) { + // Use last search pattern + const regValue = await fn.getreg(denops, "/"); + searchPattern = typeof regValue === "string" ? regValue : ""; + if (!searchPattern) { + return; + } + } + + signal?.throwIfAborted(); + + // Escape the pattern for vimgrep + const escapedPattern = searchPattern.replace(/[\\\/]/g, "\\$&"); + + // Build vimgrep command + const vimgrepCmd = `:vimgrep /${escapedPattern}/${flags} ${ + files.map((f) => `'${f.replace(/'/g, "''")}'`).join(" ") + }`; + + try { + if (useQuickfix) { + // Save current quickfix list + const savedQflist = await fn.getqflist(denops); + + // Execute vimgrep command + await denops.cmd(`silent! ${vimgrepCmd}`); + signal?.throwIfAborted(); + + // Get results from quickfix list + const qflist = await fn.getqflist(denops) as unknown as Array<{ + bufnr: number; + lnum: number; + col: number; + text: string; + valid: number; + }>; + + // Restore original quickfix list + await fn.setqflist(denops, savedQflist); + + let id = 0; + for (const item of qflist) { + if (!item.valid) { + continue; + } + + // Get filename from buffer number + let filename = ""; + if (item.bufnr > 0) { + filename = await fn.bufname(denops, item.bufnr); + } + + if (!filename) { + continue; + } + + // Format display value + const locationStr = `${filename}:${item.lnum}:${item.col}`; + const value = `${locationStr}: ${item.text}`; + + yield { + id: id++, + value, + detail: { + path: filename, + line: item.lnum, + column: item.col, + text: item.text, + pattern: searchPattern, + }, + }; + } + } else { + // For non-quickfix mode, we still need to use quickfix internally + // but we'll clean it up immediately + const savedQflist = await fn.getqflist(denops); + + await denops.cmd(`silent! ${vimgrepCmd}`); + signal?.throwIfAborted(); + + const qflist = await fn.getqflist(denops) as unknown as Array<{ + bufnr: number; + lnum: number; + col: number; + text: string; + valid: number; + }>; + + // Immediately restore + await fn.setqflist(denops, savedQflist); + + let id = 0; + for (const item of qflist) { + if (!item.valid) { + continue; + } + + let filename = ""; + if (item.bufnr > 0) { + filename = await fn.bufname(denops, item.bufnr); + } + + if (!filename) { + continue; + } + + const locationStr = `${filename}:${item.lnum}:${item.col}`; + const value = `${locationStr}: ${item.text}`; + + yield { + id: id++, + value, + detail: { + path: filename, + line: item.lnum, + column: item.col, + text: item.text, + pattern: searchPattern, + }, + }; + } + } + } catch (error) { + // Vimgrep might fail if no matches found + // This is not an error condition + if (error instanceof Error && !error.message.includes("E480")) { + throw error; + } + } + }); +} diff --git a/builtin/source/window.ts b/builtin/source/window.ts new file mode 100644 index 0000000..10c2ece --- /dev/null +++ b/builtin/source/window.ts @@ -0,0 +1,78 @@ +import * as fn from "@denops/std/function"; + +import { defineSource, type Source } from "../../source.ts"; + +type Detail = { + /** + * Window ID + */ + winid: number; + + /** + * Window number + */ + winnr: number; + + /** + * Tab number + */ + tabnr: number; + + /** + * Buffer number in the window + */ + bufnr: number; + + /** + * Buffer name in the window + */ + bufname: string; +}; + +export type WindowOptions = { + /** + * Whether to include windows from all tab pages. + * If false, only windows from the current tab page are included. + * @default false + */ + allTabs?: boolean; +}; + +/** + * Creates a Source that generates items from Vim windows. + * + * This Source retrieves window information from the current tab page or all tab pages + * and generates items for each window with details about the window and its buffer. + * + * @param options - Options to customize window listing. + * @returns A Source that generates items representing windows. + */ +export function window(options: Readonly = {}): Source { + const allTabs = options.allTabs ?? false; + return defineSource(async function* (denops, _params, { signal }) { + const wininfos = await fn.getwininfo(denops); + signal?.throwIfAborted(); + + // Filter windows based on allTabs option + const currentTabnr = allTabs ? 0 : await fn.tabpagenr(denops); + const filteredWininfos = allTabs + ? wininfos + : wininfos.filter((w) => w.tabnr === currentTabnr); + + let id = 0; + for (const wininfo of filteredWininfos) { + const bufname = await fn.bufname(denops, wininfo.bufnr); + yield { + id: id++, + value: bufname || `[No Name] (${wininfo.winnr})`, + detail: { + winid: wininfo.winid, + winnr: wininfo.winnr, + tabnr: wininfo.tabnr, + bufnr: wininfo.bufnr, + bufname: bufname, + }, + }; + } + }); +} diff --git a/deno.jsonc b/deno.jsonc index 5063f77..a5db1bc 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -37,34 +37,54 @@ "./builtin/previewer/file": "./builtin/previewer/file.ts", "./builtin/previewer/helptag": "./builtin/previewer/helptag.ts", "./builtin/previewer/noop": "./builtin/previewer/noop.ts", + "./builtin/previewer/shell": "./builtin/previewer/shell.ts", "./builtin/refiner": "./builtin/refiner/mod.ts", "./builtin/refiner/absolute-path": "./builtin/refiner/absolute_path.ts", + "./builtin/refiner/buffer-info": "./builtin/refiner/buffer_info.ts", "./builtin/refiner/cwd": "./builtin/refiner/cwd.ts", "./builtin/refiner/exists": "./builtin/refiner/exists.ts", + "./builtin/refiner/file-info": "./builtin/refiner/file_info.ts", "./builtin/refiner/noop": "./builtin/refiner/noop.ts", "./builtin/refiner/regexp": "./builtin/refiner/regexp.ts", "./builtin/refiner/relative-path": "./builtin/refiner/relative_path.ts", "./builtin/renderer": "./builtin/renderer/mod.ts", "./builtin/renderer/absolute-path": "./builtin/renderer/absolute_path.ts", + "./builtin/renderer/buffer-info": "./builtin/renderer/buffer_info.ts", + "./builtin/renderer/file-info": "./builtin/renderer/file_info.ts", "./builtin/renderer/helptag": "./builtin/renderer/helptag.ts", "./builtin/renderer/nerdfont": "./builtin/renderer/nerdfont.ts", "./builtin/renderer/noop": "./builtin/renderer/noop.ts", "./builtin/renderer/relative-path": "./builtin/renderer/relative_path.ts", + "./builtin/renderer/smart-grep": "./builtin/renderer/smart_grep.ts", "./builtin/renderer/smart-path": "./builtin/renderer/smart_path.ts", "./builtin/sorter": "./builtin/sorter/mod.ts", "./builtin/sorter/lexical": "./builtin/sorter/lexical.ts", "./builtin/sorter/noop": "./builtin/sorter/noop.ts", "./builtin/sorter/numerical": "./builtin/sorter/numerical.ts", "./builtin/source": "./builtin/source/mod.ts", + "./builtin/source/autocmd": "./builtin/source/autocmd.ts", "./builtin/source/buffer": "./builtin/source/buffer.ts", + "./builtin/source/colorscheme": "./builtin/source/colorscheme.ts", + "./builtin/source/command": "./builtin/source/command.ts", "./builtin/source/file": "./builtin/source/file.ts", + "./builtin/source/git-status": "./builtin/source/git_status.ts", + "./builtin/source/grep": "./builtin/source/grep.ts", "./builtin/source/helptag": "./builtin/source/helptag.ts", + "./builtin/source/highlight": "./builtin/source/highlight.ts", "./builtin/source/history": "./builtin/source/history.ts", + "./builtin/source/jumplist": "./builtin/source/jumplist.ts", "./builtin/source/line": "./builtin/source/line.ts", "./builtin/source/list": "./builtin/source/list.ts", + "./builtin/source/loclist": "./builtin/source/loclist.ts", + "./builtin/source/mapping": "./builtin/source/mapping.ts", + "./builtin/source/mark": "./builtin/source/mark.ts", "./builtin/source/noop": "./builtin/source/noop.ts", "./builtin/source/oldfiles": "./builtin/source/oldfiles.ts", "./builtin/source/quickfix": "./builtin/source/quickfix.ts", + "./builtin/source/register": "./builtin/source/register.ts", + "./builtin/source/tabpage": "./builtin/source/tabpage.ts", + "./builtin/source/vimgrep": "./builtin/source/vimgrep.ts", + "./builtin/source/window": "./builtin/source/window.ts", "./builtin/theme": "./builtin/theme/mod.ts", "./builtin/theme/ascii": "./builtin/theme/ascii.ts", "./builtin/theme/double": "./builtin/theme/double.ts",