diff --git a/src/completion/source/git.ts b/src/completion/source/git.ts index 3e6f814..aed6f71 100644 --- a/src/completion/source/git.ts +++ b/src/completion/source/git.ts @@ -20,521 +20,419 @@ import { GIT_STATUS_SOURCE, GIT_TAG_SOURCE, } from "../../const/source.ts"; -import type { CompletionSource } from "../../type/fzf.ts"; +import type { CompletionSource, FzfOptions } from "../../type/fzf.ts"; -export const gitSources: readonly CompletionSource[] = [ +type OptionDefaults = { + preview?: string; + multi?: boolean; + noSort?: boolean; + read0?: boolean; +}; + +type OptionOverrides = OptionDefaults & { + base?: FzfOptions; + extra?: Partial; +}; + +type SourceConfig = Readonly<{ + name: string; + patterns: readonly RegExp[]; + excludePatterns?: readonly RegExp[]; + prompt: string; + options?: OptionOverrides; + sourceCommand?: string; +}>; + +const formatPrompt = (label: string): string => "'" + label + "> '"; + +const createOptionsBuilder = ( + base: FzfOptions, + defaults: OptionDefaults, +) => (label: string, overrides: OptionOverrides = {}): FzfOptions => { + const targetBase = overrides.base ?? base; + const options: Record = { + ...targetBase, + "--prompt": formatPrompt(label), + }; + + const preview = overrides.preview ?? defaults.preview; + if (preview) { + options["--preview"] = preview; + } + + const multi = overrides.multi ?? defaults.multi; + if (multi) { + options["--multi"] = true; + } + + const noSort = overrides.noSort ?? defaults.noSort; + if (noSort) { + options["--no-sort"] = true; + } + + const read0 = overrides.read0 ?? defaults.read0; + if (read0) { + options["--read0"] = true; + } + + if (overrides.extra) { + Object.assign(options, overrides.extra); + } + + return options as FzfOptions; +}; + +const statusOptions = createOptionsBuilder(DEFAULT_OPTIONS, { + preview: GIT_STATUS_PREVIEW, + multi: true, + noSort: true, +}); + +const branchOptions = createOptionsBuilder(GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, { + preview: GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, +}); + +const logOptions = createOptionsBuilder(DEFAULT_OPTIONS, { + preview: GIT_LOG_PREVIEW, + noSort: true, +}); + +const stashOptions = createOptionsBuilder(DEFAULT_OPTIONS, { + preview: GIT_STASH_PREVIEW, +}); + +const lsFilesOptions = createOptionsBuilder(DEFAULT_OPTIONS, { + preview: GIT_LS_FILES_PREVIEW, + multi: true, + read0: true, +}); + +const statusSource = (config: SourceConfig): CompletionSource => ({ + name: config.name, + patterns: config.patterns, + excludePatterns: config.excludePatterns, + sourceCommand: GIT_STATUS_SOURCE, + options: statusOptions(config.prompt, config.options), + callback: GIT_STATUS_CALLBACK, +}); + +const branchSource = (config: SourceConfig): CompletionSource => ({ + name: config.name, + patterns: config.patterns, + excludePatterns: config.excludePatterns, + sourceCommand: config.sourceCommand ?? GIT_BRANCH_SOURCE, + options: branchOptions(config.prompt, config.options), + callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, +}); + +const lsFilesSource = (config: SourceConfig): CompletionSource => ({ + name: config.name, + patterns: config.patterns, + excludePatterns: config.excludePatterns, + sourceCommand: config.sourceCommand ?? GIT_LS_FILES_SOURCE, + options: lsFilesOptions(config.prompt, config.options), +}); + +const stashSource = (config: SourceConfig): CompletionSource => ({ + name: config.name, + patterns: config.patterns, + excludePatterns: config.excludePatterns, + sourceCommand: config.sourceCommand ?? GIT_STASH_SOURCE, + options: stashOptions(config.prompt, config.options), + callback: GIT_STASH_CALLBACK, +}); + +const logSource = (config: SourceConfig): CompletionSource => ({ + name: config.name, + patterns: config.patterns, + excludePatterns: config.excludePatterns, + sourceCommand: config.sourceCommand ?? GIT_LOG_SOURCE, + options: logOptions(config.prompt, config.options), + callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, +}); + +type SourceKind = "status" | "branch" | "ls" | "stash" | "log"; + +const builders: Record CompletionSource> = { + status: statusSource, + branch: branchSource, + ls: lsFilesSource, + stash: stashSource, + log: logSource, +}; + +const descriptors: ReadonlyArray = [ { + kind: "status", name: "git add", - patterns: [ - /^git add(?: .*)? $/, - ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--no-sort": true, - "--prompt": "'Git Add Files> '", - "--preview": GIT_STATUS_PREVIEW, - }, - callback: GIT_STATUS_CALLBACK, + patterns: [/^git add(?: .*)? $/], + prompt: "Git Add Files", }, { + kind: "status", name: "git diff files", - patterns: [ - /^git diff(?=.* -- ) .* $/, - ], + patterns: [/^git diff(?=.* -- ) .* $/], excludePatterns: [ /^git diff.* [^-].* -- /, / --no-index /, ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--no-sort": true, - "--prompt": "'Git Diff Files> '", - "--preview": GIT_STATUS_PREVIEW, - }, - callback: GIT_STATUS_CALLBACK, + prompt: "Git Diff Files", }, { + kind: "ls", name: "git diff branch files", patterns: [ /^git diff(?=.* -- ) .* $/, /^git diff(?=.* --no-index ) .* $/, ], - sourceCommand: GIT_LS_FILES_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--prompt": "'Git Diff Branch Files> '", - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + prompt: "Git Diff Branch Files", }, { + kind: "branch", name: "git diff", - patterns: [ - /^git diff(?: .*)? $/, - ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--multi": true, - "--prompt": "'Git Diff> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/^git diff(?: .*)? $/], + prompt: "Git Diff", + options: { multi: true }, }, { + kind: "log", name: "git commit", patterns: [ /^git commit(?: .*)? -[cC] $/, /^git commit(?: .*)? --fixup[= ](?:amend:|reword:)?$/, /^git commit(?: .*)? --(?:(?:reuse|reedit)-message|squash)[= ]$/, ], - excludePatterns: [ - / -- /, - ], - sourceCommand: GIT_LOG_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Commit> '", - "--no-sort": true, - "--preview": GIT_LOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + excludePatterns: [/ -- /], + prompt: "Git Commit", }, { + kind: "status", name: "git commit files", - patterns: [ - /^git commit(?: .*)? $/, - ], + patterns: [/^git commit(?: .*)? $/], excludePatterns: [ / -[mF] $/, / --(?:author|date|template|trailer) $/, ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--no-sort": true, - "--preview": GIT_STATUS_PREVIEW, - "--prompt": "'Git Commit Files> '", - }, - callback: GIT_STATUS_CALLBACK, + prompt: "Git Commit Files", }, { + kind: "ls", name: "git checkout branch files", patterns: [ /^git checkout(?=.*(? '", - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + excludePatterns: [/ --(?:conflict|pathspec-from-file) $/], + prompt: "Git Checkout Branch Files", }, { + kind: "branch", name: "git checkout", - patterns: [ - /^git checkout(?: .*)? (?:--track=)?$/, - ], + patterns: [/^git checkout(?: .*)? (?:--track=)?$/], excludePatterns: [ / -- /, / --(?:conflict|pathspec-from-file) $/, ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Checkout> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + prompt: "Git Checkout", }, { + kind: "status", name: "git checkout files", - patterns: [ - /^git checkout(?: .*)? $/, - ], - excludePatterns: [ - / --(?:conflict|pathspec-from-file) $/, - ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Checkout Files> '", - "--multi": true, - "--no-sort": true, - "--preview": GIT_STATUS_PREVIEW, - }, - callback: GIT_STATUS_CALLBACK, + patterns: [/^git checkout(?: .*)? $/], + excludePatterns: [/ --(?:conflict|pathspec-from-file) $/], + prompt: "Git Checkout Files", }, { + kind: "branch", name: "git delete branch", - patterns: [ - /^git branch (?:-d|-D)(?: .*)? $/, - ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Delete Branch> '", - "--multi": true, - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/^git branch (?:-d|-D)(?: .*)? $/], + prompt: "Git Delete Branch", + options: { base: DEFAULT_OPTIONS, multi: true }, }, { + kind: "ls", name: "git reset branch files", patterns: [ /^git reset(?=.*(? '", - "--multi": true, - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + excludePatterns: [/ --pathspec-from-file $/], + prompt: "Git Reset Branch Files", }, { + kind: "branch", name: "git reset", - patterns: [ - /^git reset(?: .*)? $/, - ], + patterns: [/^git reset(?: .*)? $/], excludePatterns: [ / -- /, / --pathspec-from-file $/, ], + prompt: "Git Reset", sourceCommand: GIT_LOG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Reset> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, }, { + kind: "status", name: "git reset files", - patterns: [ - /^git reset(?: .*)? $/, - ], - excludePatterns: [ - / --pathspec-from-file $/, - ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Reset Files> '", - "--multi": true, - "--no-sort": true, - "--preview": GIT_STATUS_PREVIEW, - }, - callback: GIT_STATUS_CALLBACK, + patterns: [/^git reset(?: .*)? $/], + excludePatterns: [/ --pathspec-from-file $/], + prompt: "Git Reset Files", }, { + kind: "branch", name: "git switch", - patterns: [ - /^git switch(?: .*)? $/, - ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Switch> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/^git switch(?: .*)? $/], + prompt: "Git Switch", }, { + kind: "branch", name: "git restore source", - patterns: [ - /^git restore(?: .*)? (?:-s |--source[= ])$/, - ], - excludePatterns: [ - / -- /, - ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Restore Source> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/^git restore(?: .*)? (?:-s |--source[= ])$/], + excludePatterns: [/ -- /], + prompt: "Git Restore Source", }, { + kind: "ls", name: "git restore source files", - patterns: [ - /^git restore(?=.* (?:-s |--source[= ])) .* $/, - ], - sourceCommand: GIT_LS_FILES_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Restore Files> '", - "--multi": true, - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + patterns: [/^git restore(?=.* (?:-s |--source[= ])) .* $/], + prompt: "Git Restore Files", }, { + kind: "status", name: "git restore files", - patterns: [ - /^git restore(?: .*)? $/, - ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Restore Files> '", - "--multi": true, - "--no-sort": true, - "--preview": GIT_STATUS_PREVIEW, - }, - callback: GIT_STATUS_CALLBACK, + patterns: [/^git restore(?: .*)? $/], + prompt: "Git Restore Files", }, { + kind: "branch", name: "git rebase branch", patterns: [ /^git rebase(?=.*(? '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + prompt: "Git Rebase Branch", }, { + kind: "branch", name: "git rebase", - patterns: [ - /^git rebase(?: .*)? (?:--onto[= ])?$/, - ], + patterns: [/^git rebase(?: .*)? (?:--onto[= ])?$/], excludePatterns: [ / -[xsX] $/, / --(?:exec|strategy(?:-option)?) $/, ], + prompt: "Git Rebase", sourceCommand: GIT_LOG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Rebase> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, }, { + kind: "branch", name: "git merge branch", - patterns: [ - /^git merge(?: .*)? --into-name[= ]$/, - ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Merge Branch> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/^git merge(?: .*)? --into-name[= ]$/], + prompt: "Git Merge Branch", }, { + kind: "branch", name: "git merge", - patterns: [ - /git merge(?: .*)? $/, - ], + patterns: [/git merge(?: .*)? $/], excludePatterns: [ / -[mFsX] $/, / --(?:file|strategy(?:-option)?) $/, ], + prompt: "Git Merge", sourceCommand: GIT_LOG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Merge> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, }, { + kind: "stash", name: "git stash", patterns: [ /git stash (?:apply|drop|pop|show)(?: .*)? $/, /git stash branch(?=.* [^-]) .* $/, ], - sourceCommand: GIT_STASH_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Stash> '", - "--preview": GIT_STASH_PREVIEW, - }, - callback: GIT_STASH_CALLBACK, + prompt: "Git Stash", }, { + kind: "branch", name: "git stash branch", - patterns: [ - /git stash branch(?: .*)? $/, - ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Stash Branch> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/git stash branch(?: .*)? $/], + prompt: "Git Stash Branch", }, { + kind: "status", name: "git stash push files", - patterns: [ - /git stash push(?: .*)? $/, - ], - sourceCommand: GIT_STATUS_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--no-sort": true, - "--prompt": "'Git Stash Push Files> '", - "--preview": GIT_STATUS_PREVIEW, - }, - callback: GIT_STATUS_CALLBACK, + patterns: [/git stash push(?: .*)? $/], + prompt: "Git Stash Push Files", }, { + kind: "ls", name: "git log file", - patterns: [ - /^git log(?=.* -- ) .* $/, - ], - sourceCommand: GIT_LS_FILES_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Log File> '", - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + patterns: [/^git log(?=.* -- ) .* $/], + prompt: "Git Log File", + options: { multi: false }, }, { + kind: "branch", name: "git log", - patterns: [ - /^git log(?: .*)? $/, - ], + patterns: [/^git log(?: .*)? $/], excludePatterns: [ / --(?:skip|since|after|until|before|author|committer|date) $/, / --(?:branches|tags|remotes|glob|exclude|pretty|format) $/, / --grep(?:-reflog)? $/, / --(?:min|max)-parents $/, ], - sourceCommand: GIT_BRANCH_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Log> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + prompt: "Git Log", }, { + kind: "branch", name: "git tag list commit", patterns: [ /^git tag(?=.* (?:-l|--list) )(?: .*)? --(?:(?:no-)?(?:contains|merged)|points-at) $/, ], + prompt: "Git Tag List Commit", sourceCommand: GIT_LOG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Tag List Commit> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, }, { + kind: "branch", name: "git tag delete", - patterns: [ - /^git tag(?=.* (?:-d|--delete) )(?: .*)? $/, - ], + patterns: [/^git tag(?=.* (?:-d|--delete) )(?: .*)? $/], + prompt: "Git Tag Delete", sourceCommand: GIT_TAG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--multi": true, - "--prompt": "'Git Tag Delete> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + options: { multi: true }, }, { + kind: "branch", name: "git tag", - patterns: [ - /^git tag(?: .*)? $/, - ], + patterns: [/^git tag(?: .*)? $/], excludePatterns: [ / -[umF] $/, / --(?:local-user|format) $/, ], + prompt: "Git Tag", sourceCommand: GIT_TAG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--prompt": "'Git Tag> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, }, { + kind: "ls", name: "git mv files", - patterns: [ - /^git mv(?: .*)? $/, - ], - sourceCommand: GIT_LS_FILES_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--prompt": "'Git Mv Files> '", - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + patterns: [/^git mv(?: .*)? $/], + prompt: "Git Mv Files", }, { + kind: "ls", name: "git rm files", - patterns: [ - /^git rm(?: .*)? $/, - ], - sourceCommand: GIT_LS_FILES_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--multi": true, - "--prompt": "'Git Rm Files> '", - "--preview": GIT_LS_FILES_PREVIEW, - "--read0": true, - }, + patterns: [/^git rm(?: .*)? $/], + prompt: "Git Rm Files", }, { + kind: "branch", name: "git show", - patterns: [ - /^git show(?: .*)? $/, - ], - excludePatterns: [ - / --(?:pretty|format) $/, - ], + patterns: [/^git show(?: .*)? $/], + excludePatterns: [/ --(?:pretty|format) $/], + prompt: "Git Show", sourceCommand: GIT_LOG_SOURCE, - options: { - ...GIT_BRANCH_LOG_TAG_REFLOG_OPTIONS, - "--multi": true, - "--prompt": "'Git Show> '", - "--preview": GIT_BRANCH_LOG_TAG_REFLOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + options: { multi: true }, }, { + kind: "log", name: "git revert", - patterns: [ - /^git revert(?: .*)? $/, - ], - sourceCommand: GIT_LOG_SOURCE, - options: { - ...DEFAULT_OPTIONS, - "--prompt": "'Git Revert> '", - "--no-sort": true, - "--preview": GIT_LOG_PREVIEW, - }, - callback: GIT_BRANCH_LOG_TAG_REFLOG_CALLBACK, + patterns: [/^git revert(?: .*)? $/], + prompt: "Git Revert", }, ]; + +export const gitSources: readonly CompletionSource[] = descriptors.map((config) => + builders[config.kind](config) +); diff --git a/src/config/context-env.ts b/src/config/context-env.ts new file mode 100644 index 0000000..1418836 --- /dev/null +++ b/src/config/context-env.ts @@ -0,0 +1,48 @@ +import type { ConfigContext } from "../type/config.ts"; + +export type ContextEnv = Readonly>; + +const ENV_ALLOWLIST = new Set(["PWD", "HOME", "SHELL", "ZENO_SHELL"]); +const ENV_PREFIX_ALLOWLIST = ["ZENO_"]; + +export const detectShell = (env: ContextEnv): ConfigContext["shell"] => { + const explicit = env["ZENO_SHELL"]?.toLowerCase(); + if (explicit === "fish" || explicit?.includes("fish")) { + return "fish"; + } + if (explicit === "zsh") { + return "zsh"; + } + const shell = env["SHELL"]?.toLowerCase() ?? ""; + if (shell.includes("fish")) { + return "fish"; + } + return "zsh"; +}; + +export const collectContextEnv = ( + cwd: string, +): Record => { + const rawEnv = Deno.env.toObject(); + const record: Record = {}; + + for (const [key, value] of Object.entries(rawEnv)) { + if (ENV_ALLOWLIST.has(key)) { + record[key] = value; + continue; + } + if (ENV_PREFIX_ALLOWLIST.some((prefix) => key.startsWith(prefix))) { + record[key] = value; + } + } + + record.PWD = cwd; + return record; +}; + +export const createEnvSignature = (env: ContextEnv): string => { + const entries = Object.entries(env) + .map(([key, value]) => [key, value ?? ""] as const) + .sort((a, b) => a[0].localeCompare(b[0])); + return entries.map(([key, value]) => `${key}=${value}`).join(";"); +}; diff --git a/src/config/discovery.ts b/src/config/discovery.ts new file mode 100644 index 0000000..0542ed4 --- /dev/null +++ b/src/config/discovery.ts @@ -0,0 +1,132 @@ +import { exists, path } from "../deps.ts"; +import { DEFAULT_APP_DIR, DEFAULT_CONFIG_FILENAME, findTypeScriptFilesInDir, findYamlFilesInDir } from "./loader.ts"; +import type { ZenoEnv } from "./env.ts"; + +export type DiscoveredConfigFiles = Readonly<{ + readonly yamlFiles: readonly string[]; + readonly tsFiles: readonly string[]; +}>; + +export type DiscoverConfigFiles = (params: { + cwd: string; + env: ZenoEnv; + xdgDirs: readonly string[]; + projectRoot: string; +}) => Promise; + +const collectFromDir = async ( + dir: string, +): Promise => { + if (!await exists(dir)) { + return undefined; + } + + try { + const stat = await Deno.stat(dir); + if (!stat.isDirectory) { + return undefined; + } + + const [yamlFiles, tsFiles] = await Promise.all([ + findYamlFilesInDir(dir), + findTypeScriptFilesInDir(dir), + ]); + + if (yamlFiles.length === 0 && tsFiles.length === 0) { + return undefined; + } + + return { yamlFiles, tsFiles }; + } catch (error) { + console.error(`Failed to scan config dir ${dir}: ${error}`); + return undefined; + } +}; + +const findLegacyConfig = async ( + env: ZenoEnv, + xdgDirs: readonly string[], +): Promise => { + if (env.HOME) { + const homeConfig = path.join(env.HOME, DEFAULT_CONFIG_FILENAME); + if (await exists(homeConfig)) { + try { + await Deno.stat(homeConfig); + return homeConfig; + } catch (error) { + console.error(`Failed to load config: ${error}`); + } + } + } + + for (const baseDir of xdgDirs) { + const candidate = path.join( + baseDir, + DEFAULT_APP_DIR, + DEFAULT_CONFIG_FILENAME, + ); + if (await exists(candidate)) { + try { + await Deno.stat(candidate); + return candidate; + } catch (error) { + console.error(`Failed to load config: ${error}`); + } + } + } + + return undefined; +}; + +export const createConfigDiscovery = (): DiscoverConfigFiles => { + return async ({ env, xdgDirs, projectRoot }) => { + const yamlFiles: string[] = []; + const tsFiles: string[] = []; + const seen = new Set(); + + const appendFiles = (files: DiscoveredConfigFiles) => { + const processFiles = (source: readonly string[], target: string[]) => { + for (const file of source) { + if (seen.has(file)) { + continue; + } + seen.add(file); + target.push(file); + } + }; + + processFiles(files.yamlFiles, yamlFiles); + processFiles(files.tsFiles, tsFiles); + }; + + const tryCollectDir = async (dir: string | undefined) => { + if (!dir) { + return; + } + const result = await collectFromDir(dir); + if (result) { + appendFiles(result); + } + }; + + await tryCollectDir(path.join(projectRoot, ".zeno")); + + if (env.HOME) { + await tryCollectDir(env.HOME); + } + + for (const baseDir of xdgDirs) { + await tryCollectDir(path.join(baseDir, DEFAULT_APP_DIR)); + } + + if (yamlFiles.length === 0 && tsFiles.length === 0) { + const legacyConfig = await findLegacyConfig(env, xdgDirs); + if (legacyConfig) { + seen.add(legacyConfig); + yamlFiles.push(legacyConfig); + } + } + + return { yamlFiles, tsFiles }; + }; +}; diff --git a/src/config/manager.ts b/src/config/manager.ts index 428c04d..03def44 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -1,32 +1,43 @@ -import { exists, path, xdg } from "../deps.ts"; -import { CONFIG_FUNCTION_MARK, directoryExists, fileExists } from "../mod.ts"; +import { xdg } from "../deps.ts"; import type { ConfigContext } from "../type/config.ts"; -import type { - Settings, - Snippet, - UserCompletionSource, -} from "../type/settings.ts"; -import { - DEFAULT_APP_DIR, - DEFAULT_CONFIG_FILENAME, - findTypeScriptFilesInDir, - findYamlFilesInDir, - getDefaultSettings, - loadConfigFiles, -} from "./loader.ts"; +import type { Settings } from "../type/settings.ts"; +import { loadConfigFiles, getDefaultSettings } from "./loader.ts"; import { getEnv } from "./env.ts"; +import { + collectContextEnv, + createEnvSignature, + detectShell, + type ContextEnv, +} from "./context-env.ts"; +import { + createConfigDiscovery, + type DiscoverConfigFiles, +} from "./discovery.ts"; +import { detectProjectRoot } from "./project.ts"; +import { + createTsConfigEvaluator, + type EvaluateTsConfigs, +} from "./ts-evaluator.ts"; +import { + freezeSettings, + mergeSettingsList as defaultMergeSettings, +} from "./settings-utils.ts"; -type ConfigEnvRecord = Readonly>; +export { mergeSettingsList } from "./settings-utils.ts"; -type DiscoveredConfigFiles = Readonly<{ - readonly yamlFiles: readonly string[]; - readonly tsFiles: readonly string[]; -}>; +export type ResolveConfigContext = (params: { + cwd: string; + env: ContextEnv; + homeDirectory: string; + projectRoot: string; +}) => Promise; -type EvaluateResult = Readonly<{ - readonly settings: Settings; - readonly warnings: readonly string[]; -}>; +const isSameCacheKey = (a: CacheKey, b: CacheKey): boolean => + a.cwd === b.cwd && + a.projectRoot === b.projectRoot && + a.envSignature === b.envSignature && + a.shell === b.shell && + a.homeDirectory === b.homeDirectory; type CacheKey = Readonly<{ readonly cwd: string; @@ -40,342 +51,6 @@ type CacheEntry = | Readonly<{ source: "auto"; key: CacheKey; settings: Settings }> | Readonly<{ source: "manual"; settings: Settings }>; -type DiscoverConfigFiles = (params: { - cwd: string; - env: ReturnType; - xdgDirs: readonly string[]; - projectRoot: string; -}) => Promise; - -type ResolveConfigContext = (params: { - cwd: string; - env: ConfigEnvRecord; - homeDirectory: string; -}) => Promise; - -type EvaluateTsConfigs = ( - files: readonly string[], - context: ConfigContext, -) => Promise; - -const ENV_ALLOWLIST = new Set(["PWD", "HOME", "SHELL", "ZENO_SHELL"]); -const ENV_PREFIX_ALLOWLIST = ["ZENO_"]; - -const isSameCacheKey = (a: CacheKey, b: CacheKey): boolean => - a.cwd === b.cwd && - a.projectRoot === b.projectRoot && - a.envSignature === b.envSignature && - a.shell === b.shell && - a.homeDirectory === b.homeDirectory; - -const detectShell = (env: ConfigEnvRecord): ConfigContext["shell"] => { - const explicit = env["ZENO_SHELL"]?.toLowerCase(); - if (explicit === "fish" || explicit?.includes("fish")) { - return "fish"; - } - if (explicit === "zsh") { - return "zsh"; - } - const shell = env["SHELL"]?.toLowerCase() ?? ""; - if (shell.includes("fish")) { - return "fish"; - } - return "zsh"; -}; - -const collectContextEnv = (cwd: string): Record => { - const rawEnv = Deno.env.toObject(); - const record: Record = {}; - - for (const [key, value] of Object.entries(rawEnv)) { - if (ENV_ALLOWLIST.has(key)) { - record[key] = value; - continue; - } - if (ENV_PREFIX_ALLOWLIST.some((prefix) => key.startsWith(prefix))) { - record[key] = value; - } - } - - record.PWD = cwd; - return record; -}; - -const createEnvSignature = (env: ConfigEnvRecord): string => { - const entries = Object.entries(env) - .map(([key, value]) => [key, value ?? ""] as const) - .sort((a, b) => a[0].localeCompare(b[0])); - return entries.map(([key, value]) => `${key}=${value}`).join(";"); -}; - -const cloneAndFreezeSnippet = (snippet: Snippet): Snippet => - Object.freeze({ ...snippet }) as Snippet; - -const cloneAndFreezeCompletion = ( - completion: UserCompletionSource, -): UserCompletionSource => - Object.freeze({ ...completion }) as UserCompletionSource; - -const freezeSettings = (settings: { - snippets: readonly Snippet[]; - completions: readonly UserCompletionSource[]; -}): Settings => - Object.freeze({ - snippets: settings.snippets.map(cloneAndFreezeSnippet), - completions: settings.completions.map(cloneAndFreezeCompletion), - }) as Settings; - -export const mergeSettingsList = ( - settingsList: readonly Settings[], -): Settings => { - if (settingsList.length === 0) { - return freezeSettings(getDefaultSettings()); - } - - const merged = { - snippets: settingsList.flatMap((settings) => settings.snippets), - completions: settingsList.flatMap((settings) => settings.completions), - }; - - return freezeSettings(merged); -}; - -const normalizeSettings = (value: unknown): Settings => { - if (value && typeof value === "object") { - const maybe = value as { - snippets?: unknown; - completions?: unknown; - }; - const snippets = Array.isArray(maybe.snippets) - ? maybe.snippets as ReadonlyArray - : []; - const completions = Array.isArray(maybe.completions) - ? maybe.completions as ReadonlyArray - : []; - - return freezeSettings({ snippets, completions }); - } - - return freezeSettings(getDefaultSettings()); -}; - -export const createConfigDiscovery = (): DiscoverConfigFiles => { - const collectFromDir = async ( - dir: string, - ): Promise => { - if (!await exists(dir)) { - return undefined; - } - - try { - const stat = await Deno.stat(dir); - if (!stat.isDirectory) { - return undefined; - } - - const [yamlFiles, tsFiles] = await Promise.all([ - findYamlFilesInDir(dir), - findTypeScriptFilesInDir(dir), - ]); - - if (yamlFiles.length === 0 && tsFiles.length === 0) { - return undefined; - } - - return { yamlFiles, tsFiles }; - } catch (error) { - console.error(`Failed to scan config dir ${dir}: ${error}`); - return undefined; - } - }; - - const findLegacyConfig = async ( - env: ReturnType, - xdgDirs: readonly string[], - ): Promise => { - if (env.HOME) { - const homeConfig = path.join(env.HOME, DEFAULT_CONFIG_FILENAME); - if (await exists(homeConfig)) { - try { - await Deno.stat(homeConfig); - return homeConfig; - } catch (error) { - console.error(`Failed to load config: ${error}`); - } - } - } - - for (const baseDir of xdgDirs) { - const candidate = path.join( - baseDir, - DEFAULT_APP_DIR, - DEFAULT_CONFIG_FILENAME, - ); - if (await exists(candidate)) { - try { - await Deno.stat(candidate); - return candidate; - } catch (error) { - console.error(`Failed to load config: ${error}`); - } - } - } - - return undefined; - }; - - return async ({ env, xdgDirs, projectRoot }) => { - const yamlFiles: string[] = []; - const tsFiles: string[] = []; - const seen = new Set(); - - const appendFiles = (files: DiscoveredConfigFiles) => { - const processFiles = (source: readonly string[], target: string[]) => { - for (const file of source) { - if (seen.has(file)) { - continue; - } - seen.add(file); - target.push(file); - } - }; - - processFiles(files.yamlFiles, yamlFiles); - processFiles(files.tsFiles, tsFiles); - }; - - const tryCollectDir = async (dir: string | undefined) => { - if (!dir) { - return; - } - const result = await collectFromDir(dir); - if (result) { - appendFiles(result); - } - }; - - await tryCollectDir(path.join(projectRoot, ".zeno")); - - if (env.HOME) { - await tryCollectDir(env.HOME); - } - - for (const baseDir of xdgDirs) { - await tryCollectDir(path.join(baseDir, DEFAULT_APP_DIR)); - } - - if (yamlFiles.length === 0 && tsFiles.length === 0) { - const legacyConfig = await findLegacyConfig(env, xdgDirs); - if (legacyConfig) { - seen.add(legacyConfig); - yamlFiles.push(legacyConfig); - } - } - - return { yamlFiles, tsFiles }; - }; -}; - -const detectProjectRoot = async (cwd: string): Promise => { - let current = cwd; - while (true) { - const gitDir = path.join(current, ".git"); - if (await directoryExists(gitDir)) { - return current; - } - const packageJson = path.join(current, "package.json"); - if (await fileExists(packageJson)) { - return current; - } - - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return cwd; -}; - -export const createConfigContextResolver = (): ResolveConfigContext => { - return async ({ cwd, env, homeDirectory }) => { - const projectRoot = await detectProjectRoot(cwd); - const shell = detectShell(env); - - return { - projectRoot, - currentDirectory: cwd, - env, - shell, - homeDirectory, - }; - }; -}; - -export const createTsConfigEvaluator = ( - logger: Pick = console, -): EvaluateTsConfigs => { - const importModule = async (filePath: string): Promise => { - const fileUrl = path.toFileUrl(filePath); - let version = ""; - try { - const stat = await Deno.stat(filePath); - const mtime = stat.mtime?.getTime() ?? Date.now(); - version = `?v=${mtime}`; - } catch { - version = `?v=${Date.now()}`; - } - return import(`${fileUrl.href}${version}`); - }; - - return async (files, context) => { - if (files.length === 0) { - return []; - } - - const results: EvaluateResult[] = []; - - for (const file of files) { - try { - const mod = await importModule(file) as { - default?: unknown; - }; - - const configFn = mod.default; - if (typeof configFn !== "function") { - throw new Error( - "TypeScript config must export default defineConfig(() => ...)", - ); - } - - const mark = Reflect.get(configFn, CONFIG_FUNCTION_MARK); - if (mark !== true) { - throw new Error( - "TypeScript config must wrap the exported function with defineConfig", - ); - } - - const value = await configFn(context); - const settings = normalizeSettings(value); - - results.push({ settings, warnings: [] }); - } catch (error) { - const message = `Failed to load TypeScript config ${file}: ${ - error instanceof Error ? error.message : String(error) - }`; - logger.error(message); - - results.push({ - settings: freezeSettings(getDefaultSettings()), - warnings: [message], - }); - } - } - - return results; - }; -}; - const createCacheKey = (context: ConfigContext): CacheKey => ({ cwd: context.currentDirectory, projectRoot: context.projectRoot, @@ -384,6 +59,16 @@ const createCacheKey = (context: ConfigContext): CacheKey => ({ homeDirectory: context.homeDirectory, }); +export const createConfigContextResolver = (): ResolveConfigContext => { + return async ({ cwd, env, homeDirectory, projectRoot }) => ({ + projectRoot, + currentDirectory: cwd, + env, + shell: detectShell(env), + homeDirectory, + }); +}; + /** * Create a config manager with caching using closure */ @@ -403,7 +88,7 @@ export const createConfigManager = (opts?: { const contextResolver = opts?.contextResolver ?? createConfigContextResolver(); const tsEvaluator = opts?.tsEvaluator ?? createTsConfigEvaluator(); - const settingsMerger = opts?.settingsMerger ?? mergeSettingsList; + const settingsMerger = opts?.settingsMerger ?? defaultMergeSettings; let cache: CacheEntry | undefined; @@ -430,6 +115,7 @@ export const createConfigManager = (opts?: { cwd, env: frozenEnv, homeDirectory, + projectRoot, }); const key = createCacheKey(context); diff --git a/src/config/project.ts b/src/config/project.ts new file mode 100644 index 0000000..693e961 --- /dev/null +++ b/src/config/project.ts @@ -0,0 +1,23 @@ +import { path } from "../deps.ts"; +import { directoryExists, fileExists } from "../mod.ts"; + +export const detectProjectRoot = async (cwd: string): Promise => { + let current = cwd; + while (true) { + const gitDir = path.join(current, ".git"); + if (await directoryExists(gitDir)) { + return current; + } + const packageJson = path.join(current, "package.json"); + if (await fileExists(packageJson)) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return cwd; +}; diff --git a/src/config/settings-utils.ts b/src/config/settings-utils.ts new file mode 100644 index 0000000..6843457 --- /dev/null +++ b/src/config/settings-utils.ts @@ -0,0 +1,51 @@ +import { getDefaultSettings } from "./loader.ts"; +import type { Settings, Snippet, UserCompletionSource } from "../type/settings.ts"; + +const cloneAndFreezeSnippet = (snippet: Snippet): Snippet => + Object.freeze({ ...snippet }) as Snippet; + +const cloneAndFreezeCompletion = ( + completion: UserCompletionSource, +): UserCompletionSource => Object.freeze({ ...completion }) as UserCompletionSource; + +export const freezeSettings = (settings: { + snippets: readonly Snippet[]; + completions: readonly UserCompletionSource[]; +}): Settings => Object.freeze({ + snippets: settings.snippets.map(cloneAndFreezeSnippet), + completions: settings.completions.map(cloneAndFreezeCompletion), +}) as Settings; + +export const mergeSettingsList = ( + settingsList: readonly Settings[], +): Settings => { + if (settingsList.length === 0) { + return freezeSettings(getDefaultSettings()); + } + + const merged = { + snippets: settingsList.flatMap((settings) => settings.snippets), + completions: settingsList.flatMap((settings) => settings.completions), + }; + + return freezeSettings(merged); +}; + +export const normalizeSettings = (value: unknown): Settings => { + if (value && typeof value === "object") { + const maybe = value as { + snippets?: unknown; + completions?: unknown; + }; + const snippets = Array.isArray(maybe.snippets) + ? maybe.snippets as ReadonlyArray + : []; + const completions = Array.isArray(maybe.completions) + ? maybe.completions as ReadonlyArray + : []; + + return freezeSettings({ snippets, completions }); + } + + return freezeSettings(getDefaultSettings()); +}; diff --git a/src/config/ts-evaluator.ts b/src/config/ts-evaluator.ts new file mode 100644 index 0000000..78ae27e --- /dev/null +++ b/src/config/ts-evaluator.ts @@ -0,0 +1,80 @@ +import { path } from "../deps.ts"; +import { CONFIG_FUNCTION_MARK } from "../mod.ts"; +import type { ConfigContext } from "../type/config.ts"; +import { getDefaultSettings } from "./loader.ts"; +import { freezeSettings, normalizeSettings } from "./settings-utils.ts"; +import type { Settings } from "../type/settings.ts"; + +export type EvaluateResult = Readonly<{ + readonly settings: Settings; + readonly warnings: readonly string[]; +}>; + +export type EvaluateTsConfigs = ( + files: readonly string[], + context: ConfigContext, +) => Promise; + +const importModule = async (filePath: string): Promise => { + const fileUrl = path.toFileUrl(filePath); + let version = ""; + try { + const stat = await Deno.stat(filePath); + const mtime = stat.mtime?.getTime() ?? Date.now(); + version = `?v=${mtime}`; + } catch { + version = `?v=${Date.now()}`; + } + return import(`${fileUrl.href}${version}`); +}; + +export const createTsConfigEvaluator = ( + logger: Pick = console, +): EvaluateTsConfigs => { + return async (files, context) => { + if (files.length === 0) { + return []; + } + + const results: EvaluateResult[] = []; + + for (const file of files) { + try { + const mod = await importModule(file) as { + default?: unknown; + }; + + const configFn = mod.default; + if (typeof configFn !== "function") { + throw new Error( + "TypeScript config must export default defineConfig(() => ...)", + ); + } + + const mark = Reflect.get(configFn, CONFIG_FUNCTION_MARK); + if (mark !== true) { + throw new Error( + "TypeScript config must wrap the exported function with defineConfig", + ); + } + + const value = await configFn(context); + const settings = normalizeSettings(value); + + results.push({ settings, warnings: [] }); + } catch (error) { + const message = `Failed to load TypeScript config ${file}: ${ + error instanceof Error ? error.message : String(error) + }`; + logger.error(message); + + results.push({ + settings: freezeSettings(getDefaultSettings()), + warnings: [message], + }); + } + } + + return results; + }; +}; diff --git a/src/snippet/auto-snippet.ts b/src/snippet/auto-snippet.ts index cfcbdf2..4781d24 100644 --- a/src/snippet/auto-snippet.ts +++ b/src/snippet/auto-snippet.ts @@ -1,7 +1,7 @@ import { loadSnippets } from "./settings.ts"; import { normalizeCommand, parseCommand } from "../command.ts"; -import { executeCommand } from "../util/exec.ts"; import type { Input } from "../type/shell.ts"; +import { extractSnippetContent } from "./snippet-utils.ts"; /** * Result of auto-snippet expansion @@ -62,8 +62,6 @@ export const autoSnippet = async ( lbufferWithoutLastWord += `${tokens.slice(0, -1).join(" ")} `; } - const placeholderRegex = /\{\{[^{}\s]*\}\}/; - const snippets = await loadSnippets(); for (const { snippet, keyword, context, evaluate } of snippets) { if (keyword !== lastWord) { @@ -92,19 +90,16 @@ export const autoSnippet = async ( } } - let snipText = snippet; - if (evaluate === true) { - snipText = await executeCommand(snippet); - } - - const placeholderMatch = placeholderRegex.exec(snipText); + const { text: snipText, placeholderIndex } = await extractSnippetContent( + snippet, + evaluate, + ); let cursor = lbufferWithoutLastWord.length; - if (placeholderMatch == null) { + if (placeholderIndex == null) { cursor += snipText.length + 1; } else { - snipText = snipText.replace(placeholderRegex, ""); - cursor += placeholderMatch.index; + cursor += placeholderIndex; } let newBuffer = `${lbufferWithoutLastWord}${snipText}${rbuffer}`; diff --git a/src/snippet/insert-snippet.ts b/src/snippet/insert-snippet.ts index 208a2fe..92ef86b 100644 --- a/src/snippet/insert-snippet.ts +++ b/src/snippet/insert-snippet.ts @@ -1,7 +1,7 @@ import { loadSnippets } from "./settings.ts"; import { normalizeCommand } from "../command.ts"; -import { executeCommand } from "../util/exec.ts"; import type { Input } from "../type/shell.ts"; +import { extractSnippetContent } from "./snippet-utils.ts"; export type InsertSnippetData = { status: "success"; @@ -24,31 +24,23 @@ export const insertSnippet = async ( }); const snippetName = (input.snippet ?? "").trim(); - const placeholderRegex = /\{\{[^{}\s]*\}\}/; - const snippets = await loadSnippets(); for (const { snippet, name, evaluate } of snippets) { if (name == null || snippetName !== name.trim()) { continue; } - let snipText = snippet; - if (evaluate === true) { - snipText = await executeCommand(snippet); - } - - const placeholderMatch = placeholderRegex.exec(snipText); + const { text: snipText, placeholderIndex } = await extractSnippetContent( + snippet, + evaluate, + ); - let cursor = snipText.length + 1; - if (placeholderMatch != null) { - snipText = snipText.replace(placeholderRegex, ""); - cursor = placeholderMatch.index; - } + const cursorOffset = placeholderIndex ?? (snipText.length + 1); return { status: "success", buffer: `${lbuffer}${snipText}${rbuffer} `, - cursor: (lbuffer.length + cursor), + cursor: lbuffer.length + cursorOffset, } as const; } diff --git a/src/snippet/next-placeholder.ts b/src/snippet/next-placeholder.ts index fc025f2..886bad1 100644 --- a/src/snippet/next-placeholder.ts +++ b/src/snippet/next-placeholder.ts @@ -1,5 +1,6 @@ import { normalizeCommand } from "../command.ts"; import type { Input } from "../type/shell.ts"; +import { findPlaceholder } from "./snippet-utils.ts"; export const nextPlaceholder = ( input: Input, @@ -7,15 +8,14 @@ export const nextPlaceholder = ( const lbuffer = input.lbuffer ?? ""; const rbuffer = input.rbuffer ?? ""; const buffer = normalizeCommand(`${lbuffer}${rbuffer}`); - const placeholderRegex = /\{\{[^{}\s]*\}\}/; - const placeholderMatch = placeholderRegex.exec(buffer); - if (placeholderMatch == null) { + const placeholder = findPlaceholder(buffer); + if (placeholder == null) { return null; } return { - nextBuffer: buffer.replace(placeholderRegex, ""), - index: placeholderMatch.index, + nextBuffer: placeholder.nextBuffer, + index: placeholder.index, }; }; diff --git a/src/snippet/snippet-utils.ts b/src/snippet/snippet-utils.ts new file mode 100644 index 0000000..7ff7d45 --- /dev/null +++ b/src/snippet/snippet-utils.ts @@ -0,0 +1,40 @@ +import { executeCommand } from "../util/exec.ts"; + +const PLACEHOLDER_PATTERN = "\\{\\{[^{}\\s]*\\}\\}"; + +const createPlaceholderRegex = () => new RegExp(PLACEHOLDER_PATTERN); + +export const extractSnippetContent = async ( + snippet: string, + evaluate: boolean | undefined, +): Promise<{ + text: string; + placeholderIndex: number | null; +}> => { + const resolved = evaluate === true ? await executeCommand(snippet) : snippet; + const regex = createPlaceholderRegex(); + const match = regex.exec(resolved); + if (!match) { + return { text: resolved, placeholderIndex: null }; + } + + return { + text: resolved.replace(regex, ""), + placeholderIndex: match.index, + }; +}; + +export const findPlaceholder = ( + buffer: string, +): { index: number; nextBuffer: string } | null => { + const regex = createPlaceholderRegex(); + const match = regex.exec(buffer); + if (!match) { + return null; + } + + return { + index: match.index, + nextBuffer: buffer.replace(regex, ""), + }; +};