diff --git a/README.md b/README.md index 6c7f7d7..b53dc3d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,32 @@ $ git clone https://github.com/yuki-yano/zeno.zsh.git $ echo "source /path/to/dir/zeno.zsh" >> ~/.zshrc ``` +`source /path/to/dir/zeno.zsh` remains the default installation method and +still performs the full initialization eagerly, so existing users can update +without changing their setup. + +### Lazy-load for Zsh + +If you want lazy-load, source `zeno-bootstrap.zsh` and use the upstream lazy key +API: + +```zsh +source /path/to/dir/zeno-bootstrap.zsh +zeno-bind-default-keys --lazy +zsh-defer zeno-preload +``` + +For a custom bind: + +```zsh +source /path/to/dir/zeno-bootstrap.zsh +zeno-register-lazy-widget zeno-completion +bindkey '^i' zeno-completion +``` + +Details for state variables, public APIs, and fallback behavior are in +[Lazy-load APIs for Zsh](#lazy-load-apis-for-zsh). + ## Fish Shell Support (Experimental) Fish support is experimental. A quick overview is included here; full @@ -204,6 +230,14 @@ export ZENO_GIT_TREE="tree" # git folder preview with color # export ZENO_GIT_TREE="eza --tree" +# upstream default key set +# zeno-bind-default-keys + +# upstream lazy key set +# source /path/to/dir/zeno-bootstrap.zsh +# zeno-bind-default-keys --lazy +# zsh-defer zeno-preload + if [[ -n $ZENO_LOADED ]]; then bindkey ' ' zeno-auto-snippet @@ -491,6 +525,52 @@ history: togglePreview: ? # Toggle the preview window ``` +## Lazy-load APIs for Zsh + +Public state: + +- `ZENO_BOOTSTRAPPED=1`: bootstrap is complete and zeno functions / widgets are available +- `ZENO_LOADED=1`: heavy init is complete and socket / history / preprompt are ready + +Bootstrap (`source zeno-bootstrap.zsh`): + +- sets `ZENO_ROOT` +- appends `bin` / `fpath` +- autoloads functions and widgets +- registers ZLE widgets with their original names +- sets `ZENO_BOOTSTRAPPED=1` + +Heavy init (`zeno-init` / `zeno-ensure-loaded`): + +- optional `deno cache` +- socket setup (`zeno-enable-sock`) +- history hooks (`zeno-history-hooks`) +- preprompt hooks (`zeno-preprompt-hooks`) +- sets `ZENO_LOADED=1` + +Public APIs: + +- `zeno-init` +- `zeno-ensure-loaded` +- `zeno-preload` +- `zeno-register-lazy-widget ` +- `zeno-register-lazy-widgets ...` +- `zeno-bind-default-keys` +- `zeno-bind-default-keys --lazy` + +Builtin lazy fallback behavior: + +- `zeno-completion` -> `ZENO_COMPLETION_FALLBACK`, otherwise `fzf-completion` or `expand-or-complete` +- `zeno-auto-snippet` -> `ZENO_AUTO_SNIPPET_FALLBACK`, otherwise `self-insert` +- `zeno-auto-snippet-and-accept-line` -> `accept-line` +- `zeno-history-selection` -> `history-incremental-search-backward` +- `zeno-smart-history-selection` -> `zeno-history-selection`, otherwise `history-incremental-search-backward` +- `zeno-insert-space` -> literal space insertion + +This keeps widget names unchanged, so eager and lazy setups can both use +`bindkey '^i' zeno-completion` and similar bindings without user-defined wrapper +widgets. + ## Fish usage ### Installation for Fish diff --git a/shells/zsh/functions/zeno-bind-default-keys b/shells/zsh/functions/zeno-bind-default-keys new file mode 100644 index 0000000..91c518e --- /dev/null +++ b/shells/zsh/functions/zeno-bind-default-keys @@ -0,0 +1,51 @@ +#autoload + +function zeno-bind-default-keys() { + emulate -L zsh + + local lazy=0 arg + local -a lazy_widgets + + for arg in "$@"; do + case "$arg" in + --lazy) + lazy=1 + ;; + *) + print -u2 -- "zeno-bind-default-keys: unsupported option: $arg" + return 1 + ;; + esac + done + + lazy_widgets=( + zeno-auto-snippet + zeno-auto-snippet-and-accept-line + zeno-completion + zeno-insert-snippet + zeno-preprompt + zeno-preprompt-snippet + zeno-history-selection + ) + + if (( lazy )); then + zeno-register-lazy-widgets "${(@)lazy_widgets}" || return $? + else + zeno-ensure-loaded || return $? + local widget_name + for widget_name in "${(@)lazy_widgets}"; do + zle -N -- "$widget_name" || return $? + done + fi + + bindkey ' ' zeno-auto-snippet + bindkey '^m' zeno-auto-snippet-and-accept-line + bindkey '^i' zeno-completion + bindkey '^xx' zeno-insert-snippet + bindkey '^x ' zeno-insert-space + bindkey '^x^m' accept-line + bindkey '^x^z' zeno-toggle-auto-snippet + bindkey '^xp' zeno-preprompt + bindkey '^xs' zeno-preprompt-snippet + bindkey '^r' zeno-history-selection +} diff --git a/shells/zsh/functions/zeno-enable-sock b/shells/zsh/functions/zeno-enable-sock index bea88a0..501b248 100644 --- a/shells/zsh/functions/zeno-enable-sock +++ b/shells/zsh/functions/zeno-enable-sock @@ -140,6 +140,9 @@ function zeno-set-pid() { } autoload -Uz add-zsh-hook +add-zsh-hook -d precmd zeno-set-pid 2>/dev/null +add-zsh-hook -d chpwd zeno-onchpwd 2>/dev/null +add-zsh-hook -d zshexit zeno-stop-server 2>/dev/null add-zsh-hook precmd zeno-set-pid add-zsh-hook chpwd zeno-onchpwd add-zsh-hook zshexit zeno-stop-server diff --git a/shells/zsh/functions/zeno-ensure-loaded b/shells/zsh/functions/zeno-ensure-loaded new file mode 100644 index 0000000..06d922c --- /dev/null +++ b/shells/zsh/functions/zeno-ensure-loaded @@ -0,0 +1,11 @@ +#autoload + +function zeno-ensure-loaded() { + emulate -L zsh + + if [[ ${ZENO_LOADED-} == 1 ]]; then + return 0 + fi + + zeno-init "$@" +} diff --git a/shells/zsh/functions/zeno-init b/shells/zsh/functions/zeno-init new file mode 100644 index 0000000..0ba382b --- /dev/null +++ b/shells/zsh/functions/zeno-init @@ -0,0 +1,48 @@ +#autoload + +function zeno-init() { + emulate -L zsh + + if [[ ${ZENO_LOADED-} == 1 ]]; then + return 0 + fi + + if [[ -z ${ZENO_ROOT-} ]]; then + print -u2 -- "zeno-init: ZENO_ROOT is not set; source zeno-bootstrap.zsh first" + return 1 + fi + + if [[ -z ${ZENO_DISABLE_EXECUTE_CACHE_COMMAND-} ]]; then + command deno cache --node-modules-dir=auto --no-lock --no-check -- "${ZENO_ROOT}/src/cli.ts" || + return $? + fi + + if [[ -z ${ZENO_DISABLE_SOCK-} ]]; then + local raw_deno_version deno_version + local -a v_parts + raw_deno_version=$(deno -V 2>/dev/null) + v_parts=(${(s:.:)${${(z)raw_deno_version}[2]-0.0.0}}) + v_parts[1]=${v_parts[1]//[^0-9]/} + v_parts[2]=${v_parts[2]//[^0-9]/} + v_parts[3]=${${v_parts[3]-0}%%[^0-9]*} + printf -v deno_version '%d%02d%02d' \ + ${v_parts[1]:-0} \ + ${v_parts[2]:-0} \ + ${v_parts[3]:-0} + if (( deno_version >= 11600 )); then + zeno-enable-sock || return $? + else + export ZENO_DISABLE_SOCK=1 + fi + fi + + if (( $+functions[zeno-history-hooks] )); then + zeno-history-hooks || return $? + fi + + if (( $+functions[zeno-preprompt-hooks] )); then + zeno-preprompt-hooks || return $? + fi + + export ZENO_LOADED=1 +} diff --git a/shells/zsh/functions/zeno-lazy-widget-dispatch b/shells/zsh/functions/zeno-lazy-widget-dispatch new file mode 100644 index 0000000..2c86813 --- /dev/null +++ b/shells/zsh/functions/zeno-lazy-widget-dispatch @@ -0,0 +1,22 @@ +#autoload + +function zeno-lazy-widget-dispatch() { + emulate -L zsh + + local widget_name="${WIDGET-}" + if [[ -z $widget_name ]]; then + return 1 + fi + + if ! zeno-ensure-loaded; then + zeno-run-lazy-fallback "$widget_name" + return $? + fi + + if (( $+functions[$widget_name] )); then + "$widget_name" + return $? + fi + + return 1 +} diff --git a/shells/zsh/functions/zeno-preload b/shells/zsh/functions/zeno-preload new file mode 100644 index 0000000..d3087ea --- /dev/null +++ b/shells/zsh/functions/zeno-preload @@ -0,0 +1,7 @@ +#autoload + +function zeno-preload() { + emulate -L zsh + + zeno-ensure-loaded "$@" +} diff --git a/shells/zsh/functions/zeno-register-lazy-widget b/shells/zsh/functions/zeno-register-lazy-widget new file mode 100644 index 0000000..c7604ce --- /dev/null +++ b/shells/zsh/functions/zeno-register-lazy-widget @@ -0,0 +1,20 @@ +#autoload + +function zeno-register-lazy-widget() { + emulate -L zsh + + local widget_name="$1" + if [[ -z $widget_name ]]; then + print -u2 -- "zeno-register-lazy-widget: widget name is required" + return 1 + fi + + if [[ ${ZENO_BOOTSTRAPPED-} != 1 ]]; then + print -u2 -- "zeno-register-lazy-widget: source zeno-bootstrap.zsh first" + return 1 + fi + + typeset -gA ZENO_LAZY_WIDGETS + ZENO_LAZY_WIDGETS[$widget_name]=1 + zle -N -- "$widget_name" zeno-lazy-widget-dispatch +} diff --git a/shells/zsh/functions/zeno-register-lazy-widgets b/shells/zsh/functions/zeno-register-lazy-widgets new file mode 100644 index 0000000..38dd168 --- /dev/null +++ b/shells/zsh/functions/zeno-register-lazy-widgets @@ -0,0 +1,10 @@ +#autoload + +function zeno-register-lazy-widgets() { + emulate -L zsh + + local widget_name + for widget_name in "$@"; do + zeno-register-lazy-widget "$widget_name" || return $? + done +} diff --git a/shells/zsh/functions/zeno-run-lazy-fallback b/shells/zsh/functions/zeno-run-lazy-fallback new file mode 100644 index 0000000..fcc4b8a --- /dev/null +++ b/shells/zsh/functions/zeno-run-lazy-fallback @@ -0,0 +1,47 @@ +#autoload + +function zeno-run-lazy-fallback() { + emulate -L zsh + + local widget_name="$1" + local fallback_widget + + case "$widget_name" in + zeno-completion) + fallback_widget=${ZENO_COMPLETION_FALLBACK:-${${widgets[fzf-completion]+fzf-completion}:-expand-or-complete}} + zle "$fallback_widget" + ;; + zeno-auto-snippet) + fallback_widget=${ZENO_AUTO_SNIPPET_FALLBACK:-self-insert} + zle "$fallback_widget" + ;; + zeno-auto-snippet-and-accept-line) + zle accept-line + ;; + zeno-history-selection) + zle history-incremental-search-backward + ;; + zeno-smart-history-selection) + if (( $+widgets[zeno-history-selection] || $+functions[zeno-history-selection] )); then + zle zeno-history-selection + else + zle history-incremental-search-backward + fi + ;; + zeno-insert-space) + LBUFFER+=" " + if [[ -n ${RBUFFER+x} ]]; then + BUFFER="${LBUFFER}${RBUFFER}" + fi + if (( $+CURSOR )); then + CURSOR=${#LBUFFER} + fi + ;; + zeno-insert-snippet|zeno-preprompt|zeno-preprompt-snippet|zeno-ghq-cd) + zle redisplay 2>/dev/null || true + ;; + *) + return 1 + ;; + esac +} diff --git a/shells/zsh/widgets/zeno-completion b/shells/zsh/widgets/zeno-completion index 202ea93..4478aea 100644 --- a/shells/zsh/widgets/zeno-completion +++ b/shells/zsh/widgets/zeno-completion @@ -14,7 +14,7 @@ out=( "${(f)$(zeno-call-client-and-fallback --zeno-mode=completion \ )}" ) if [[ $out[1] != success ]]; then - zle ${ZENO_COMPLETION_FALLBACK:-${${functions[fzf-completion]+fzf-completion}:-expand-or-complete}} + zle ${ZENO_COMPLETION_FALLBACK:-${${widgets[fzf-completion]+fzf-completion}:-expand-or-complete}} return fi diff --git a/src/config/loader.ts b/src/config/loader.ts index 05ee1e8..1e7ce14 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -35,6 +35,68 @@ export const parseXdgConfigDirs = (raw: string): readonly string[] => { .filter((dir) => dir.length > 0); }; +const appendUniqueDir = ( + target: string[], + seen: Set, + dir: string | undefined, +): void => { + const trimmed = dir?.trim(); + if (!trimmed) { + return; + } + + let normalized = path.normalize(trimmed); + const { root } = path.parse(normalized); + while ( + normalized.length > root.length && + /[\\/]/.test(normalized[normalized.length - 1] ?? "") + ) { + normalized = normalized.slice(0, -1); + } + + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +}; + +export const getXdgConfigBaseDirs = (params?: { + xdgConfigHome?: string | undefined; + xdgConfigDirs?: readonly string[]; + fallbackConfigDirs?: readonly string[]; + homeDirectory?: string | undefined; +}): readonly string[] => { + const dirs: string[] = []; + const seen = new Set(); + const xdgConfigHome = + (params?.xdgConfigHome ?? Deno.env.get("XDG_CONFIG_HOME"))?.trim() || + undefined; + const homeDirectory = + (params?.homeDirectory ?? Deno.env.get("HOME"))?.trim() || undefined; + const fallbackConfigHome = xdgConfigHome + ? undefined + : homeDirectory + ? path.join(homeDirectory.trim(), ".config") + : undefined; + + appendUniqueDir(dirs, seen, xdgConfigHome); + appendUniqueDir(dirs, seen, fallbackConfigHome); + + const xdgConfigDirs = params?.xdgConfigDirs ?? + parseXdgConfigDirs(Deno.env.get("XDG_CONFIG_DIRS") ?? ""); + for (const dir of xdgConfigDirs) { + appendUniqueDir(dirs, seen, dir); + } + + const fallbackConfigDirs = params?.fallbackConfigDirs ?? xdg.configDirs(); + for (const dir of fallbackConfigDirs) { + appendUniqueDir(dirs, seen, dir); + } + + return dirs; +}; + export const getDefaultSettings = (): Settings => ({ snippets: [], completions: [], @@ -102,43 +164,17 @@ export const findConfigFilePath = async (): Promise => { return path.join(env.HOME, DEFAULT_CONFIG_FILENAME); } - const configCandidates: string[] = []; - - const xdgConfigHome = Deno.env.get("XDG_CONFIG_HOME"); - if (xdgConfigHome) { - configCandidates.push( - path.join(xdgConfigHome, DEFAULT_APP_DIR, DEFAULT_CONFIG_FILENAME), - ); - } - - const envConfigDirs = parseXdgConfigDirs( - Deno.env.get("XDG_CONFIG_DIRS") ?? "", - ); - - configCandidates.push( - ...envConfigDirs.map((baseDir) => - path.join(baseDir, DEFAULT_APP_DIR, DEFAULT_CONFIG_FILENAME) - ), - ); - - configCandidates.push( - ...xdg.configDirs().map((baseDir) => - path.join(baseDir, DEFAULT_APP_DIR, DEFAULT_CONFIG_FILENAME) - ), + const configCandidates = getXdgConfigBaseDirs().map((baseDir) => + path.join(baseDir, DEFAULT_APP_DIR, DEFAULT_CONFIG_FILENAME) ); - const seen = new Set(); for (const candidate of configCandidates) { - if (seen.has(candidate)) { - continue; - } - seen.add(candidate); if (await exists(candidate)) { return candidate; } } - const [firstCandidate] = seen; + const [firstCandidate] = configCandidates; if (firstCandidate) { return firstCandidate; } diff --git a/src/config/manager.ts b/src/config/manager.ts index 7b1049b..f0d17c4 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -20,7 +20,9 @@ import { findTypeScriptFilesInDir, findYamlFilesInDir, getDefaultSettings, + getXdgConfigBaseDirs, loadConfigFiles, + parseXdgConfigDirs, } from "./loader.ts"; import { getEnv } from "./env.ts"; @@ -488,7 +490,17 @@ export const createConfigManager = (opts?: { const cwd = cwdProvider(); const zenoEnv = envProvider(); const envSignatureZeno = createZenoEnvSignature(zenoEnv); - const xdgDirs = xdgConfigDirsProvider(); + const xdgConfigHome = Deno.env.get("XDG_CONFIG_HOME") ?? ""; + const xdgConfigDirsRaw = Deno.env.get("XDG_CONFIG_DIRS") ?? ""; + const xdgDirs = getXdgConfigBaseDirs({ + xdgConfigHome, + xdgConfigDirs: [ + ...parseXdgConfigDirs(xdgConfigDirsRaw), + ...xdgConfigDirsProvider(), + ], + fallbackConfigDirs: [], + homeDirectory: Deno.env.get("HOME"), + }); const projectRoot = await detectProjectRoot(cwd); const discovered = await discovery({ cwd, @@ -505,6 +517,8 @@ export const createConfigManager = (opts?: { const combinedSignature = JSON.stringify([ contextSignature, envSignatureZeno, + xdgConfigHome, + xdgConfigDirsRaw, ]); const key = createCacheKey(context, combinedSignature); if (cache) { diff --git a/test/config/loader_test.ts b/test/config/loader_test.ts index 69c8460..a752e4d 100644 --- a/test/config/loader_test.ts +++ b/test/config/loader_test.ts @@ -1,5 +1,8 @@ import { assertEquals, path } from "../deps.ts"; -import { parseXdgConfigDirs } from "../../src/config/loader.ts"; +import { + getXdgConfigBaseDirs, + parseXdgConfigDirs, +} from "../../src/config/loader.ts"; const detectDelimiter = (): string => { const maybePath = path as unknown as { @@ -30,3 +33,69 @@ Deno.test("parseXdgConfigDirs splits and trims entries", () => { Deno.test("parseXdgConfigDirs returns empty array for empty input", () => { assertEquals(parseXdgConfigDirs(""), []); }); + +Deno.test("getXdgConfigBaseDirs adds ~/.config when XDG_CONFIG_HOME is unset", () => { + const result = getXdgConfigBaseDirs({ + xdgConfigHome: "", + xdgConfigDirs: ["/etc/xdg"], + fallbackConfigDirs: ["/Library/Preferences"], + homeDirectory: "/tmp/test-home", + }); + + assertEquals(result, [ + "/tmp/test-home/.config", + "/etc/xdg", + "/Library/Preferences", + ]); +}); + +Deno.test( + "getXdgConfigBaseDirs treats whitespace-only XDG_CONFIG_HOME as unset", + () => { + const result = getXdgConfigBaseDirs({ + xdgConfigHome: " ", + xdgConfigDirs: ["/etc/xdg"], + fallbackConfigDirs: ["/Library/Preferences"], + homeDirectory: " /tmp/test-home ", + }); + + assertEquals(result, [ + "/tmp/test-home/.config", + "/etc/xdg", + "/Library/Preferences", + ]); + }, +); + +Deno.test("getXdgConfigBaseDirs deduplicates overlapping config dirs", () => { + const result = getXdgConfigBaseDirs({ + xdgConfigHome: "/tmp/test-home/.config", + xdgConfigDirs: ["/etc/xdg", "/tmp/test-home/.config"], + fallbackConfigDirs: ["/etc/xdg", "/Library/Preferences"], + homeDirectory: "/tmp/test-home", + }); + + assertEquals(result, [ + "/tmp/test-home/.config", + "/etc/xdg", + "/Library/Preferences", + ]); +}); + +Deno.test( + "getXdgConfigBaseDirs deduplicates paths that differ only by trailing separators", + () => { + const result = getXdgConfigBaseDirs({ + xdgConfigHome: "/tmp/test-home/.config/", + xdgConfigDirs: ["/etc/xdg/", "/etc/xdg"], + fallbackConfigDirs: ["/Library/Preferences/", "/Library/Preferences"], + homeDirectory: "/tmp/test-home", + }); + + assertEquals(result, [ + "/tmp/test-home/.config", + "/etc/xdg", + "/Library/Preferences", + ]); + }, +); diff --git a/test/config/manager_multi_yaml_test.ts b/test/config/manager_multi_yaml_test.ts index 876b4a6..8ca7c93 100644 --- a/test/config/manager_multi_yaml_test.ts +++ b/test/config/manager_multi_yaml_test.ts @@ -28,6 +28,10 @@ describe("config manager - multi YAML loading", () => { beforeEach(() => { clearCache(); + const tempDir = context.getTempDir(); + Deno.env.set("HOME", tempDir); + Deno.env.set("XDG_CONFIG_HOME", path.join(tempDir, "xdg-config-home")); + Deno.env.set("XDG_CONFIG_DIRS", ""); }); afterEach(() => { @@ -213,6 +217,54 @@ history: assertEquals(settings.history.redact, ["password"]); }); + it("merges YAML files under ~/.config/zeno when $XDG_CONFIG_HOME is unset", async () => { + const tempDir = context.getTempDir(); + + Deno.env.set("HOME", tempDir); + Deno.env.delete("XDG_CONFIG_HOME"); + + const appDir = path.join(tempDir, ".config", "zeno"); + Deno.mkdirSync(appDir, { recursive: true }); + + Deno.writeTextFileSync( + path.join(appDir, "01.yml"), + ` +snippets: + - keyword: home-a + snippet: from-home-a +`, + ); + Deno.writeTextFileSync( + path.join(appDir, "02.yaml"), + ` +completions: + - name: home-b + patterns: ["^hb "] + sourceCommand: echo hb + callback: echo hb {} +`, + ); + + const manager = createConfigManager({ + envProvider: () => ({ + DEFAULT_FZF_OPTIONS: "", + SOCK: undefined, + GIT_CAT: "cat", + GIT_TREE: "tree", + DISABLE_BUILTIN_COMPLETION: false, + DISABLE_AUTOMATIC_WORKSPACE_LOOKUP: false, + LOCAL_CONFIG_PATH: undefined, + HOME: undefined, + }), + xdgConfigDirsProvider: () => [], + }); + + const settings = await manager.getSettings(); + assertEquals(settings.snippets.map((s) => s.keyword), ["home-a"]); + assertEquals(settings.completions.map((c) => c.name), ["home-b"]); + assertEquals(settings.history, defaultHistory); + }); + it("falls back to $ZENO_HOME/config.yml when no YAML groups exist", async () => { const tempDir = context.getTempDir(); diff --git a/test/config/manager_typescript_test.ts b/test/config/manager_typescript_test.ts index 324bb37..8ba64b8 100644 --- a/test/config/manager_typescript_test.ts +++ b/test/config/manager_typescript_test.ts @@ -31,6 +31,10 @@ describe("config manager - TypeScript configs", () => { beforeEach(() => { clearCache(); + const tempDir = helper.getTempDir(); + Deno.env.set("HOME", tempDir); + Deno.env.set("XDG_CONFIG_HOME", path.join(tempDir, "xdg-config-home")); + Deno.env.set("XDG_CONFIG_DIRS", ""); }); afterEach(() => { diff --git a/test/settings_test.ts b/test/settings_test.ts index df963ac..dfb434c 100644 --- a/test/settings_test.ts +++ b/test/settings_test.ts @@ -112,6 +112,22 @@ describe("settings", () => { assertEquals(configFile, expectedPath); }); + + it("returns path in ~/.config/zeno when $XDG_CONFIG_HOME is unset", async () => { + const tempDir = context.getTempDir(); + + Deno.env.delete("ZENO_HOME"); + Deno.env.delete("XDG_CONFIG_HOME"); + Deno.env.set("XDG_CONFIG_DIRS", ""); + Deno.env.set("HOME", tempDir); + + const configFile = await findConfigFile(); + + assertEquals( + configFile, + path.join(tempDir, ".config", "zeno", "config.yml"), + ); + }); }); describe("loadConfigFile()", () => { @@ -252,6 +268,87 @@ snippets: ); }); + it("returns Settings from ~/.config/zeno/config.yml when $XDG_CONFIG_HOME is unset", async () => { + const tempDir = context.getTempDir(); + + Deno.env.delete("ZENO_HOME"); + Deno.env.delete("XDG_CONFIG_HOME"); + Deno.env.set("XDG_CONFIG_DIRS", ""); + Deno.env.set("HOME", tempDir); + + const configDir = path.join(tempDir, ".config", "zeno"); + const configFile = path.join(configDir, "config.yml"); + Deno.mkdirSync(configDir, { recursive: true }); + Deno.writeTextFileSync( + configFile, + ` +snippets: + - keyword: home + snippet: from-home-config +`, + ); + + const settings = await getSettings(); + + assertEquals( + settings, + withHistoryDefaults({ + snippets: [{ keyword: "home", snippet: "from-home-config" }], + completions: [], + }), + ); + }); + + it("reloads Settings when $XDG_CONFIG_HOME changes", async () => { + const tempDir = context.getTempDir(); + + Deno.env.delete("ZENO_HOME"); + Deno.env.set("XDG_CONFIG_DIRS", ""); + + const xdgHomeA = path.join(tempDir, "xdg-home-a"); + const xdgHomeB = path.join(tempDir, "xdg-home-b"); + const configDirA = path.join(xdgHomeA, "zeno"); + const configDirB = path.join(xdgHomeB, "zeno"); + Deno.mkdirSync(configDirA, { recursive: true }); + Deno.mkdirSync(configDirB, { recursive: true }); + Deno.writeTextFileSync( + path.join(configDirA, "config.yml"), + ` +snippets: + - keyword: from-a + snippet: a +`, + ); + Deno.writeTextFileSync( + path.join(configDirB, "config.yml"), + ` +snippets: + - keyword: from-b + snippet: b +`, + ); + + Deno.env.set("XDG_CONFIG_HOME", xdgHomeA); + const settingsA = await getSettings(); + assertEquals( + settingsA, + withHistoryDefaults({ + snippets: [{ keyword: "from-a", snippet: "a" }], + completions: [], + }), + ); + + Deno.env.set("XDG_CONFIG_HOME", xdgHomeB); + const settingsB = await getSettings(); + assertEquals( + settingsB, + withHistoryDefaults({ + snippets: [{ keyword: "from-b", snippet: "b" }], + completions: [], + }), + ); + }); + it("returns Settings from cache", async () => { const tempDir = context.getTempDir(); diff --git a/test/shell/zsh_completion_widget_basic_test.ts b/test/shell/zsh_completion_widget_basic_test.ts index 3dda1ed..02d095c 100644 --- a/test/shell/zsh_completion_widget_basic_test.ts +++ b/test/shell/zsh_completion_widget_basic_test.ts @@ -138,4 +138,59 @@ describe("zsh completion widget basic behavior", () => { assertEquals(result.buffer, "echo [fallback]"); assertEquals(result.lastZleCall, "test-completion-fallback"); }); + + it("falls back to expand-or-complete when fzf-completion is only a function", async () => { + if (!await hasZsh()) { + return; + } + + const tempDir = await Deno.makeTempDir({ + prefix: "zeno-zsh-completion-basic-fallback-", + }); + + try { + const script = [ + "emulate -L zsh", + "LAST_ZLE_CALL=''", + "function zle() {", + ' if [[ "$1" == "-N" ]]; then', + " return 0", + " fi", + ' LAST_ZLE_CALL="$1"', + "}", + "function fzf-completion() { return 0 }", + "function expand-or-complete() { return 0 }", + "function zeno-call-client-and-fallback() {", + " print failure", + "}", + `fpath=(${shellQuote(ZSH_WIDGETS_DIR)} $fpath)`, + "autoload -Uz zeno-completion", + "unset ZENO_COMPLETION_FALLBACK", + "LBUFFER='echo '", + "BUFFER='echo '", + "RBUFFER=''", + "zeno-completion", + 'print -r -- "$LAST_ZLE_CALL"', + "", + ].join("\n"); + + const result = await new Deno.Command("zsh", { + args: ["-lc", script], + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + + if (!result.success) { + const stderr = new TextDecoder().decode(result.stderr).trimEnd(); + throw new Error(`zsh completion scenario failed: ${stderr}`); + } + + const stdout = new TextDecoder().decode(result.stdout).trimEnd(); + const lines = stdout.split("\n"); + assertEquals(lines[lines.length - 1], "expand-or-complete"); + } finally { + await Deno.remove(tempDir, { recursive: true }).catch(() => undefined); + } + }); }); diff --git a/test/shell/zsh_init_test.ts b/test/shell/zsh_init_test.ts new file mode 100644 index 0000000..8a8b851 --- /dev/null +++ b/test/shell/zsh_init_test.ts @@ -0,0 +1,448 @@ +import { assertEquals, describe, it, path } from "../deps.ts"; +import { + hasZsh, + parseNullSeparatedPairs, + REPO_ROOT, + shellQuote, + ZSH_BOOTSTRAP_ENTRYPOINT, + ZSH_ENTRYPOINT, +} from "./zsh_test_utils.ts"; + +const runZshScript = async ( + script: string, +): Promise> => { + const result = await new Deno.Command("zsh", { + args: ["-dfc", script], + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + + if (!result.success) { + const stderr = new TextDecoder().decode(result.stderr).trimEnd(); + throw new Error(`zsh init scenario failed: ${stderr}`); + } + + return parseNullSeparatedPairs(result.stdout); +}; + +const runZshScriptRaw = async ( + script: string, +): Promise => + await new Deno.Command("zsh", { + args: ["-dfc", script], + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + +const createStubAutoloadDir = async (): Promise => { + const dir = await Deno.makeTempDir({ prefix: "zeno-zsh-init-stubs-" }); + const stubs = [ + { + name: "zeno-enable-sock", + body: [ + "#autoload", + "typeset -gi ZENO_TEST_SOCK_CALLS=${ZENO_TEST_SOCK_CALLS:-0}", + "ZENO_TEST_SOCK_CALLS=$(( ZENO_TEST_SOCK_CALLS + 1 ))", + "typeset -g ZENO_TEST_SOCK_ENABLED=1", + "", + ].join("\n"), + }, + { + name: "zeno-history-hooks", + body: [ + "#autoload", + "typeset -gi ZENO_TEST_HISTORY_HOOK_CALLS=${ZENO_TEST_HISTORY_HOOK_CALLS:-0}", + "ZENO_TEST_HISTORY_HOOK_CALLS=$(( ZENO_TEST_HISTORY_HOOK_CALLS + 1 ))", + "typeset -g ZENO_TEST_HISTORY_HOOK_ENABLED=1", + "", + ].join("\n"), + }, + { + name: "zeno-preprompt-hooks", + body: [ + "#autoload", + "typeset -gi ZENO_TEST_PREPROMPT_HOOK_CALLS=${ZENO_TEST_PREPROMPT_HOOK_CALLS:-0}", + "ZENO_TEST_PREPROMPT_HOOK_CALLS=$(( ZENO_TEST_PREPROMPT_HOOK_CALLS + 1 ))", + "typeset -g ZENO_TEST_PREPROMPT_HOOK_ENABLED=1", + "", + ].join("\n"), + }, + ]; + + for (const stub of stubs) { + const filePath = path.join(dir, stub.name); + await Deno.writeTextFile(filePath, stub.body); + } + + return dir; +}; + +const createPrintHelpers = (): string[] => [ + "function zeno-test-print-kv() {", + ' local key="$1"', + ' local value="$2"', + ' print -rn -- "$key"', + " print -rn -- $'\\0'", + ' print -rn -- "$value"', + " print -rn -- $'\\0'", + "}", + "function zeno-test-is-widget-registered() {", + ' local widget_name="$1"', + " if (( ${REGISTERED_WIDGETS[(Ie)$widget_name]} > 0 )); then", + ' print -rn -- "1"', + " else", + ' print -rn -- "0"', + " fi", + "}", +]; + +const createSetupLines = (): string[] => [ + "unset ZENO_ROOT ZENO_ENABLE ZENO_LOADED ZENO_FZF_COMMAND ZENO_DISABLE_SOCK", + "unset ZENO_TEST_SOCK_CALLS ZENO_TEST_HISTORY_HOOK_CALLS ZENO_TEST_PREPROMPT_HOOK_CALLS", + "function zle() {", + ' if [[ "$1" == "-N" ]]; then', + ' local widget_name="$2"', + ' if [[ "$widget_name" == "--" ]]; then', + ' widget_name="$3"', + " fi", + " if (( $+parameters[REGISTERED_WIDGETS] )); then", + ' REGISTERED_WIDGETS+=("$widget_name")', + " fi", + " return 0", + " fi", + " if (( $+functions[$1] )); then", + ' "$1"', + " return $?", + " fi", + " return 0", + "}", +]; + +describe("zsh initialization entrypoints", () => { + it("source zeno.zsh keeps the existing eager initialization behavior", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + + try { + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "typeset -ga REGISTERED_WIDGETS", + ...createSetupLines(), + ...createPrintHelpers(), + 'zeno-test-print-kv "ZERO_BEFORE" "$0"', + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + `source ${shellQuote(ZSH_ENTRYPOINT)}`, + 'zeno-test-print-kv "ZERO_AFTER" "$0"', + 'zeno-test-print-kv "ZENO_ROOT" "${ZENO_ROOT-}"', + 'zeno-test-print-kv "ZENO_BOOTSTRAPPED" "${ZENO_BOOTSTRAPPED-}"', + 'zeno-test-print-kv "ZENO_ENABLE" "${ZENO_ENABLE-}"', + 'zeno-test-print-kv "ZENO_LOADED" "${ZENO_LOADED-}"', + 'zeno-test-print-kv "SOCK_CALLS" "${ZENO_TEST_SOCK_CALLS:-0}"', + 'zeno-test-print-kv "HISTORY_HOOK_CALLS" "${ZENO_TEST_HISTORY_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "PREPROMPT_HOOK_CALLS" "${ZENO_TEST_PREPROMPT_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "HAS_BIN_PATH" "$(( ${path[(I)$ZENO_ROOT/bin]} > 0 ? 1 : 0 ))"', + 'zeno-test-print-kv "HAS_FUNCTIONS_FPATH" "$(( ${fpath[(I)$ZENO_ROOT/shells/zsh/functions]} > 0 ? 1 : 0 ))"', + 'zeno-test-print-kv "HAS_WIDGETS_FPATH" "$(( ${fpath[(I)$ZENO_ROOT/shells/zsh/widgets]} > 0 ? 1 : 0 ))"', + 'zeno-test-print-kv "COMPLETION_WIDGET_REGISTERED" "$(zeno-test-is-widget-registered zeno-completion)"', + "", + ].join("\n")); + + assertEquals(parsed.ZENO_ROOT, REPO_ROOT); + assertEquals(parsed.ZERO_AFTER, parsed.ZERO_BEFORE); + assertEquals(parsed.ZENO_BOOTSTRAPPED, "1"); + assertEquals(parsed.ZENO_ENABLE, "1"); + assertEquals(parsed.ZENO_LOADED, "1"); + assertEquals(parsed.SOCK_CALLS, "1"); + assertEquals(parsed.HISTORY_HOOK_CALLS, "1"); + assertEquals(parsed.PREPROMPT_HOOK_CALLS, "1"); + assertEquals(parsed.HAS_BIN_PATH, "1"); + assertEquals(parsed.HAS_FUNCTIONS_FPATH, "1"); + assertEquals(parsed.HAS_WIDGETS_FPATH, "1"); + assertEquals(parsed.COMPLETION_WIDGET_REGISTERED, "1"); + } finally { + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); + + it("source zeno-bootstrap.zsh defers heavy initialization until zeno-init is called", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + + try { + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "typeset -ga REGISTERED_WIDGETS", + ...createSetupLines(), + ...createPrintHelpers(), + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + 'zeno-test-print-kv "BOOTSTRAPPED" "${ZENO_BOOTSTRAPPED-}"', + 'zeno-test-print-kv "BEFORE_LOADED" "${ZENO_LOADED-}"', + 'zeno-test-print-kv "BEFORE_SOCK_CALLS" "${ZENO_TEST_SOCK_CALLS:-0}"', + 'zeno-test-print-kv "BEFORE_HISTORY_HOOK_CALLS" "${ZENO_TEST_HISTORY_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "BEFORE_PREPROMPT_HOOK_CALLS" "${ZENO_TEST_PREPROMPT_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "COMPLETION_WIDGET_REGISTERED" "$(zeno-test-is-widget-registered zeno-completion)"', + "zeno-init", + 'zeno-test-print-kv "AFTER_LOADED" "${ZENO_LOADED-}"', + 'zeno-test-print-kv "AFTER_SOCK_CALLS" "${ZENO_TEST_SOCK_CALLS:-0}"', + 'zeno-test-print-kv "AFTER_HISTORY_HOOK_CALLS" "${ZENO_TEST_HISTORY_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "AFTER_PREPROMPT_HOOK_CALLS" "${ZENO_TEST_PREPROMPT_HOOK_CALLS:-0}"', + "", + ].join("\n")); + + assertEquals(parsed.BOOTSTRAPPED, "1"); + assertEquals(parsed.BEFORE_LOADED, ""); + assertEquals(parsed.BEFORE_SOCK_CALLS, "0"); + assertEquals(parsed.BEFORE_HISTORY_HOOK_CALLS, "0"); + assertEquals(parsed.BEFORE_PREPROMPT_HOOK_CALLS, "0"); + assertEquals(parsed.COMPLETION_WIDGET_REGISTERED, "1"); + assertEquals(parsed.AFTER_LOADED, "1"); + assertEquals(parsed.AFTER_SOCK_CALLS, "1"); + assertEquals(parsed.AFTER_HISTORY_HOOK_CALLS, "1"); + assertEquals(parsed.AFTER_PREPROMPT_HOOK_CALLS, "1"); + } finally { + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); + + it("source zeno-bootstrap.zsh from a wrapper keeps ZENO_ROOT anchored to the bootstrap file", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + const wrapperPath = await Deno.makeTempFile({ + prefix: "zeno-bootstrap-wrapper-", + suffix: ".zsh", + }); + + try { + await Deno.writeTextFile( + wrapperPath, + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}\n`, + ); + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "typeset -ga REGISTERED_WIDGETS", + ...createSetupLines(), + ...createPrintHelpers(), + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + `source ${shellQuote(wrapperPath)}`, + 'zeno-test-print-kv "ZENO_ROOT" "${ZENO_ROOT-}"', + 'zeno-test-print-kv "HAS_FUNCTIONS_FPATH" "$(( ${fpath[(I)$ZENO_ROOT/shells/zsh/functions]} > 0 ? 1 : 0 ))"', + 'zeno-test-print-kv "HAS_WIDGETS_FPATH" "$(( ${fpath[(I)$ZENO_ROOT/shells/zsh/widgets]} > 0 ? 1 : 0 ))"', + "", + ].join("\n")); + + assertEquals(parsed.ZENO_ROOT, REPO_ROOT); + assertEquals(parsed.HAS_FUNCTIONS_FPATH, "1"); + assertEquals(parsed.HAS_WIDGETS_FPATH, "1"); + } finally { + await Deno.remove(wrapperPath).catch(() => undefined); + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); + + it("source zeno-bootstrap.zsh fails fast when ZENO_ROOT does not contain zsh assets", async () => { + if (!await hasZsh()) { + return; + } + + const invalidRoot = await Deno.makeTempDir({ + prefix: "zeno-invalid-root-", + }); + + try { + const result = await runZshScriptRaw([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "unset ZENO_ENABLE ZENO_LOADED ZENO_BOOTSTRAPPED ZENO_FZF_COMMAND ZENO_DISABLE_SOCK", + `export ZENO_ROOT=${shellQuote(invalidRoot)}`, + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "", + ].join("\n")); + + const stderr = new TextDecoder().decode(result.stderr); + + assertEquals(result.success, false); + assertEquals( + stderr.includes("zeno-bootstrap.zsh: missing required directory:"), + true, + ); + assertEquals( + stderr.includes(`${invalidRoot}/shells/zsh/functions`), + true, + ); + } finally { + await Deno.remove(invalidRoot, { recursive: true }).catch(() => + undefined + ); + } + }); + + it("zeno-ensure-loaded can initialize from a wrapper widget before calling the target widget", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + + try { + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "typeset -ga REGISTERED_WIDGETS", + "typeset -gi ZENO_TEST_WRAPPER_CALLS=0", + ...createSetupLines(), + ...createPrintHelpers(), + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-completion() {", + " ZENO_TEST_WRAPPER_CALLS=$(( ZENO_TEST_WRAPPER_CALLS + 1 ))", + "}", + "function zeno-lazy-wrapper() {", + " zeno-ensure-loaded || return $?", + " zle zeno-completion", + "}", + "zle -N zeno-lazy-wrapper", + "zeno-lazy-wrapper", + 'zeno-test-print-kv "LOADED" "${ZENO_LOADED-}"', + 'zeno-test-print-kv "SOCK_CALLS" "${ZENO_TEST_SOCK_CALLS:-0}"', + 'zeno-test-print-kv "HISTORY_HOOK_CALLS" "${ZENO_TEST_HISTORY_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "PREPROMPT_HOOK_CALLS" "${ZENO_TEST_PREPROMPT_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "WRAPPER_CALLS" "${ZENO_TEST_WRAPPER_CALLS:-0}"', + "", + ].join("\n")); + + assertEquals(parsed.LOADED, "1"); + assertEquals(parsed.SOCK_CALLS, "1"); + assertEquals(parsed.HISTORY_HOOK_CALLS, "1"); + assertEquals(parsed.PREPROMPT_HOOK_CALLS, "1"); + assertEquals(parsed.WRAPPER_CALLS, "1"); + } finally { + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); + + it("zeno-init does not mark Zeno as loaded when a heavy init step fails", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + + try { + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "typeset -ga REGISTERED_WIDGETS", + ...createSetupLines(), + ...createPrintHelpers(), + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + "export ZENO_DISABLE_SOCK=1", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-history-hooks() {", + " return 7", + "}", + "zeno-init >/dev/null 2>&1", + 'zeno-test-print-kv "STATUS" "$?"', + 'zeno-test-print-kv "LOADED" "${ZENO_LOADED-}"', + "", + ].join("\n")); + + assertEquals(parsed.STATUS, "7"); + assertEquals(parsed.LOADED, ""); + } finally { + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); + + it("zeno-init accepts pre-release Deno versions when enabling the socket", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + + try { + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + "typeset -ga REGISTERED_WIDGETS", + ...createSetupLines(), + ...createPrintHelpers(), + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function deno() {", + ' if [[ "$1" == "-V" ]]; then', + " print -- 'deno 1.16.0-alpha'", + " return 0", + " fi", + " return 1", + "}", + "zeno-init >/dev/null 2>&1", + 'zeno-test-print-kv "STATUS" "$?"', + 'zeno-test-print-kv "LOADED" "${ZENO_LOADED-}"', + 'zeno-test-print-kv "SOCK_CALLS" "${ZENO_TEST_SOCK_CALLS:-0}"', + "", + ].join("\n")); + + assertEquals(parsed.STATUS, "0"); + assertEquals(parsed.LOADED, "1"); + assertEquals(parsed.SOCK_CALLS, "1"); + } finally { + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); + + it("zeno-init is idempotent and avoids duplicate heavy registrations", async () => { + if (!await hasZsh()) { + return; + } + + const stubDir = await createStubAutoloadDir(); + + try { + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + `fpath=(${shellQuote(stubDir)} $fpath)`, + "export ZENO_DISABLE_EXECUTE_CACHE_COMMAND=1", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "zeno-init", + "zeno-init", + "zeno-ensure-loaded", + 'zeno-test-print-kv "SOCK_CALLS" "${ZENO_TEST_SOCK_CALLS:-0}"', + 'zeno-test-print-kv "HISTORY_HOOK_CALLS" "${ZENO_TEST_HISTORY_HOOK_CALLS:-0}"', + 'zeno-test-print-kv "PREPROMPT_HOOK_CALLS" "${ZENO_TEST_PREPROMPT_HOOK_CALLS:-0}"', + "", + ].join("\n")); + + assertEquals(parsed.SOCK_CALLS, "1"); + assertEquals(parsed.HISTORY_HOOK_CALLS, "1"); + assertEquals(parsed.PREPROMPT_HOOK_CALLS, "1"); + } finally { + await Deno.remove(stubDir, { recursive: true }).catch(() => undefined); + } + }); +}); diff --git a/test/shell/zsh_lazy_api_test.ts b/test/shell/zsh_lazy_api_test.ts new file mode 100644 index 0000000..a66832b --- /dev/null +++ b/test/shell/zsh_lazy_api_test.ts @@ -0,0 +1,408 @@ +import { assertEquals, describe, it } from "../deps.ts"; +import { + hasZsh, + parseNullSeparatedPairs, + shellQuote, + ZSH_BOOTSTRAP_ENTRYPOINT, +} from "./zsh_test_utils.ts"; + +const runZshScript = async ( + script: string, +): Promise> => { + const result = await new Deno.Command("zsh", { + args: ["-dfc", script], + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + + if (!result.success) { + const stderr = new TextDecoder().decode(result.stderr).trimEnd(); + throw new Error(`zsh lazy api scenario failed: ${stderr}`); + } + + return parseNullSeparatedPairs(result.stdout); +}; + +const createPrintHelpers = (): string[] => [ + "function zeno-test-print-kv() {", + ' local key="$1"', + ' local value="$2"', + ' print -rn -- "$key"', + " print -rn -- $'\\0'", + ' print -rn -- "$value"', + " print -rn -- $'\\0'", + "}", +]; + +const createSetupLines = (): string[] => [ + "unset ZENO_ROOT ZENO_ENABLE ZENO_LOADED ZENO_BOOTSTRAPPED ZENO_FZF_COMMAND ZENO_DISABLE_SOCK", + "unset ZENO_COMPLETION_FALLBACK ZENO_AUTO_SNIPPET_FALLBACK", + "typeset -gA ZENO_TEST_WIDGET_MAP", + "typeset -ga ZENO_TEST_ZLE_LOG", + "typeset -ga ZENO_TEST_BINDKEY_LOG", + "function zle() {", + ' if [[ "$1" == "-N" ]]; then', + ' local widget_name="$2"', + ' local widget_function="$3"', + ' if [[ "$widget_name" == "--" ]]; then', + ' widget_name="$3"', + ' widget_function="$4"', + " fi", + ' if [[ -z "$widget_function" ]]; then', + ' widget_function="$widget_name"', + " fi", + ' ZENO_TEST_WIDGET_MAP[$widget_name]="$widget_function"', + " return 0", + " fi", + ' ZENO_TEST_ZLE_LOG+=("$1")', + ' local widget_function="${ZENO_TEST_WIDGET_MAP[$1]-$1}"', + " if (( $+functions[$widget_function] )); then", + ' local WIDGET="$1"', + ' "$widget_function"', + " return $?", + " fi", + " return 0", + "}", + "function bindkey() {", + ' ZENO_TEST_BINDKEY_LOG+=("$*")', + "}", +]; + +describe("zsh lazy public api", () => { + it("zeno-register-lazy-widget ensures load before executing the real widget", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_ENSURE_CALLS=0", + "typeset -gi ZENO_TEST_WIDGET_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " ZENO_TEST_ENSURE_CALLS=$(( ZENO_TEST_ENSURE_CALLS + 1 ))", + " typeset -g ZENO_LOADED=1", + "}", + "function zeno-completion() {", + " ZENO_TEST_WIDGET_CALLS=$(( ZENO_TEST_WIDGET_CALLS + 1 ))", + "}", + "zeno-register-lazy-widget zeno-completion", + "zle zeno-completion", + 'zeno-test-print-kv "ENSURE_CALLS" "${ZENO_TEST_ENSURE_CALLS}"', + 'zeno-test-print-kv "WIDGET_CALLS" "${ZENO_TEST_WIDGET_CALLS}"', + "", + ].join("\n")); + + assertEquals(parsed.ENSURE_CALLS, "1"); + assertEquals(parsed.WIDGET_CALLS, "1"); + }); + + it("lazy widget fallback uses upstream defaults when ensure-loaded fails", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_COMPLETION_WIDGET_CALLS=0", + "typeset -gi ZENO_TEST_COMPLETION_FALLBACK_CALLS=0", + "typeset -gi ZENO_TEST_AUTO_SNIPPET_FALLBACK_CALLS=0", + "typeset -gi ZENO_TEST_SMART_HISTORY_FALLBACK_CALLS=0", + "typeset -gi ZENO_TEST_SPACE_FALLBACK_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " return 1", + "}", + "function zeno-completion() {", + " ZENO_TEST_COMPLETION_WIDGET_CALLS=$(( ZENO_TEST_COMPLETION_WIDGET_CALLS + 1 ))", + "}", + "function expand-or-complete() {", + " ZENO_TEST_COMPLETION_FALLBACK_CALLS=$(( ZENO_TEST_COMPLETION_FALLBACK_CALLS + 1 ))", + "}", + "function self-insert() {", + " ZENO_TEST_AUTO_SNIPPET_FALLBACK_CALLS=$(( ZENO_TEST_AUTO_SNIPPET_FALLBACK_CALLS + 1 ))", + "}", + "function zeno-history-selection() {", + " ZENO_TEST_SMART_HISTORY_FALLBACK_CALLS=$(( ZENO_TEST_SMART_HISTORY_FALLBACK_CALLS + 1 ))", + "}", + "zeno-register-lazy-widget zeno-completion", + "zeno-register-lazy-widget zeno-auto-snippet", + "zeno-register-lazy-widget zeno-smart-history-selection", + "zeno-register-lazy-widget zeno-insert-space", + "zle zeno-completion", + "zle zeno-auto-snippet", + "zle zeno-smart-history-selection", + "BUFFER=''", + "LBUFFER=''", + "RBUFFER=''", + "CURSOR=0", + "zle zeno-insert-space", + 'zeno-test-print-kv "COMPLETION_WIDGET_CALLS" "${ZENO_TEST_COMPLETION_WIDGET_CALLS}"', + 'zeno-test-print-kv "COMPLETION_FALLBACK_CALLS" "${ZENO_TEST_COMPLETION_FALLBACK_CALLS}"', + 'zeno-test-print-kv "AUTO_SNIPPET_FALLBACK_CALLS" "${ZENO_TEST_AUTO_SNIPPET_FALLBACK_CALLS}"', + 'zeno-test-print-kv "SMART_HISTORY_FALLBACK_CALLS" "${ZENO_TEST_SMART_HISTORY_FALLBACK_CALLS}"', + 'zeno-test-print-kv "SPACE_BUFFER" "${BUFFER-}"', + 'zeno-test-print-kv "SPACE_LBUFFER" "${LBUFFER-}"', + 'zeno-test-print-kv "SPACE_CURSOR" "${CURSOR-}"', + "", + ].join("\n")); + + assertEquals(parsed.COMPLETION_WIDGET_CALLS, "0"); + assertEquals(parsed.COMPLETION_FALLBACK_CALLS, "1"); + assertEquals(parsed.AUTO_SNIPPET_FALLBACK_CALLS, "1"); + assertEquals(parsed.SMART_HISTORY_FALLBACK_CALLS, "1"); + assertEquals(parsed.SPACE_BUFFER, " "); + assertEquals(parsed.SPACE_LBUFFER, " "); + assertEquals(parsed.SPACE_CURSOR, "1"); + }); + + it("lazy completion fallback ignores plain functions that are not registered widgets", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_FZF_COMPLETION_CALLS=0", + "typeset -gi ZENO_TEST_EXPAND_OR_COMPLETE_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function fzf-completion() {", + " ZENO_TEST_FZF_COMPLETION_CALLS=$(( ZENO_TEST_FZF_COMPLETION_CALLS + 1 ))", + "}", + "function expand-or-complete() {", + " ZENO_TEST_EXPAND_OR_COMPLETE_CALLS=$(( ZENO_TEST_EXPAND_OR_COMPLETE_CALLS + 1 ))", + "}", + "zeno-run-lazy-fallback zeno-completion", + 'zeno-test-print-kv "FZF_COMPLETION_CALLS" "${ZENO_TEST_FZF_COMPLETION_CALLS}"', + 'zeno-test-print-kv "EXPAND_OR_COMPLETE_CALLS" "${ZENO_TEST_EXPAND_OR_COMPLETE_CALLS}"', + 'zeno-test-print-kv "LAST_ZLE_CALL" "${ZENO_TEST_ZLE_LOG[-1]-}"', + "", + ].join("\n")); + + assertEquals(parsed.FZF_COMPLETION_CALLS, "0"); + assertEquals(parsed.EXPAND_OR_COMPLETE_CALLS, "1"); + assertEquals(parsed.LAST_ZLE_CALL, "expand-or-complete"); + }); + + it("smart history fallback invokes the history widget through zle", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_WIDGET_CALLS=0", + "typeset -gi ZENO_TEST_DIRECT_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-history-selection() {", + " if [[ ${WIDGET-} == zeno-history-selection ]]; then", + " ZENO_TEST_WIDGET_CALLS=$(( ZENO_TEST_WIDGET_CALLS + 1 ))", + " else", + " ZENO_TEST_DIRECT_CALLS=$(( ZENO_TEST_DIRECT_CALLS + 1 ))", + " fi", + "}", + "zle -N zeno-history-selection", + "zeno-run-lazy-fallback zeno-smart-history-selection", + 'zeno-test-print-kv "WIDGET_CALLS" "${ZENO_TEST_WIDGET_CALLS}"', + 'zeno-test-print-kv "DIRECT_CALLS" "${ZENO_TEST_DIRECT_CALLS}"', + 'zeno-test-print-kv "LAST_ZLE_CALL" "${ZENO_TEST_ZLE_LOG[-1]-}"', + "", + ].join("\n")); + + assertEquals(parsed.WIDGET_CALLS, "1"); + assertEquals(parsed.DIRECT_CALLS, "0"); + assertEquals(parsed.LAST_ZLE_CALL, "zeno-history-selection"); + }); + + it("zeno-bind-default-keys supports lazy mode without user-defined wrappers", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_ENSURE_CALLS=0", + "typeset -gi ZENO_TEST_COMPLETION_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " ZENO_TEST_ENSURE_CALLS=$(( ZENO_TEST_ENSURE_CALLS + 1 ))", + " typeset -g ZENO_LOADED=1", + "}", + "function zeno-completion() {", + " ZENO_TEST_COMPLETION_CALLS=$(( ZENO_TEST_COMPLETION_CALLS + 1 ))", + "}", + "zeno-bind-default-keys --lazy", + "zle zeno-completion", + 'zeno-test-print-kv "ENSURE_CALLS" "${ZENO_TEST_ENSURE_CALLS}"', + 'zeno-test-print-kv "COMPLETION_CALLS" "${ZENO_TEST_COMPLETION_CALLS}"', + 'zeno-test-print-kv "BIND_SPACE" "${ZENO_TEST_BINDKEY_LOG[1]-}"', + 'zeno-test-print-kv "BIND_TAB" "${ZENO_TEST_BINDKEY_LOG[3]-}"', + 'zeno-test-print-kv "BIND_HISTORY" "${ZENO_TEST_BINDKEY_LOG[10]-}"', + "", + ].join("\n")); + + assertEquals(parsed.ENSURE_CALLS, "1"); + assertEquals(parsed.COMPLETION_CALLS, "1"); + assertEquals(parsed.BIND_SPACE, " zeno-auto-snippet"); + assertEquals(parsed.BIND_TAB, "^i zeno-completion"); + assertEquals(parsed.BIND_HISTORY, "^r zeno-history-selection"); + }); + + it("zeno-bind-default-keys keeps lightweight widgets usable without heavy init in lazy mode", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_ENSURE_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " ZENO_TEST_ENSURE_CALLS=$(( ZENO_TEST_ENSURE_CALLS + 1 ))", + " return 0", + "}", + "BUFFER=''", + "LBUFFER=''", + "RBUFFER=''", + "zeno-bind-default-keys --lazy", + "zle zeno-insert-space", + "zle zeno-toggle-auto-snippet", + 'zeno-test-print-kv "ENSURE_CALLS" "${ZENO_TEST_ENSURE_CALLS}"', + 'zeno-test-print-kv "LBUFFER" "${LBUFFER-}"', + 'zeno-test-print-kv "ZENO_ENABLE" "${ZENO_ENABLE-}"', + "", + ].join("\n")); + + assertEquals(parsed.ENSURE_CALLS, "0"); + assertEquals(parsed.LBUFFER, " "); + assertEquals(parsed.ZENO_ENABLE, "0"); + }); + + it("zeno-bind-default-keys returns early when lazy widget registration fails", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-register-lazy-widgets() {", + " return 23", + "}", + "zeno-bind-default-keys --lazy >/dev/null 2>&1", + 'zeno-test-print-kv "STATUS" "$?"', + 'zeno-test-print-kv "BIND_COUNT" "${#ZENO_TEST_BINDKEY_LOG[@]}"', + "", + ].join("\n")); + + assertEquals(parsed.STATUS, "23"); + assertEquals(parsed.BIND_COUNT, "0"); + }); + + it("zeno-preload is a public preload alias for ensure-loaded", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_ENSURE_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " ZENO_TEST_ENSURE_CALLS=$(( ZENO_TEST_ENSURE_CALLS + 1 ))", + " typeset -g ZENO_LOADED=1", + "}", + "zeno-preload", + 'zeno-test-print-kv "ENSURE_CALLS" "${ZENO_TEST_ENSURE_CALLS}"', + 'zeno-test-print-kv "LOADED" "${ZENO_LOADED-}"', + "", + ].join("\n")); + + assertEquals(parsed.ENSURE_CALLS, "1"); + assertEquals(parsed.LOADED, "1"); + }); + + it("zeno-bind-default-keys supports eager mode and initializes before binding", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + "typeset -gi ZENO_TEST_ENSURE_CALLS=0", + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " ZENO_TEST_ENSURE_CALLS=$(( ZENO_TEST_ENSURE_CALLS + 1 ))", + " typeset -g ZENO_LOADED=1", + "}", + "zeno-bind-default-keys", + 'zeno-test-print-kv "ENSURE_CALLS" "${ZENO_TEST_ENSURE_CALLS}"', + 'zeno-test-print-kv "LOADED" "${ZENO_LOADED-}"', + 'zeno-test-print-kv "BIND_TAB" "${ZENO_TEST_BINDKEY_LOG[3]-}"', + "", + ].join("\n")); + + assertEquals(parsed.ENSURE_CALLS, "1"); + assertEquals(parsed.LOADED, "1"); + assertEquals(parsed.BIND_TAB, "^i zeno-completion"); + }); + + it("zeno-bind-default-keys returns early when eager widget registration fails", async () => { + if (!await hasZsh()) { + return; + } + + const parsed = await runZshScript([ + "emulate -L zsh", + "unsetopt err_return err_exit", + ...createSetupLines(), + ...createPrintHelpers(), + `source ${shellQuote(ZSH_BOOTSTRAP_ENTRYPOINT)}`, + "function zeno-ensure-loaded() {", + " return 0", + "}", + "function zle() {", + ' if [[ "$1" == "-N" ]]; then', + " return 17", + " fi", + ' ZENO_TEST_ZLE_LOG+=("$1")', + " return 0", + "}", + "zeno-bind-default-keys >/dev/null 2>&1", + 'zeno-test-print-kv "STATUS" "$?"', + 'zeno-test-print-kv "BIND_COUNT" "${#ZENO_TEST_BINDKEY_LOG[@]}"', + "", + ].join("\n")); + + assertEquals(parsed.STATUS, "17"); + assertEquals(parsed.BIND_COUNT, "0"); + }); +}); diff --git a/test/shell/zsh_test_utils.ts b/test/shell/zsh_test_utils.ts index 8dfb347..b8dc8a2 100644 --- a/test/shell/zsh_test_utils.ts +++ b/test/shell/zsh_test_utils.ts @@ -3,6 +3,7 @@ import { path } from "../deps.ts"; const TEST_DIR = path.dirname(path.fromFileUrl(import.meta.url)); const REPO_ROOT = path.resolve(TEST_DIR, "..", ".."); +export { REPO_ROOT }; export const ZSH_FUNCTIONS_DIR = path.join( REPO_ROOT, "shells", @@ -10,6 +11,11 @@ export const ZSH_FUNCTIONS_DIR = path.join( "functions", ); export const ZSH_WIDGETS_DIR = path.join(REPO_ROOT, "shells", "zsh", "widgets"); +export const ZSH_ENTRYPOINT = path.join(REPO_ROOT, "zeno.zsh"); +export const ZSH_BOOTSTRAP_ENTRYPOINT = path.join( + REPO_ROOT, + "zeno-bootstrap.zsh", +); export const toHeredoc = (lines: readonly string[]): string => lines.length === 0 ? "" : `${lines.join("\n")}\n`; diff --git a/zeno-bootstrap.zsh b/zeno-bootstrap.zsh new file mode 100644 index 0000000..f4667b6 --- /dev/null +++ b/zeno-bootstrap.zsh @@ -0,0 +1,59 @@ +if ! whence -p deno >/dev/null; then + return +fi + +() { + emulate -L zsh + + local zeno_source=${${(%):-%x}:A} + local -a required_dirs widget_dirs autoload_dirs + local dir f + + export ZENO_ROOT=${ZENO_ROOT:-${zeno_source:h}} + + required_dirs=( + "${ZENO_ROOT}/shells/zsh/functions" + "${ZENO_ROOT}/shells/zsh/widgets" + ) + + for dir in "${(@)required_dirs}"; do + if [[ ! -d "$dir" ]]; then + print -u2 -- "zeno-bootstrap.zsh: missing required directory: $dir" + return 1 + fi + done + + if (( ${path[(I)${ZENO_ROOT}/bin]} == 0 )); then + path+=("${ZENO_ROOT}/bin") + fi + + widget_dirs=( + "${ZENO_ROOT}/shells/zsh/widgets" + ) + autoload_dirs=( + "${ZENO_ROOT}/shells/zsh/functions" + "${(@)widget_dirs}" + ) + + for dir in "${(@)autoload_dirs}"; do + if (( ${fpath[(I)$dir]} == 0 )); then + fpath+=("$dir") + fi + done + + for f in "${(@)^autoload_dirs}"/*(N-.); do + autoload -Uz -- "${f:t}" + done + for f in "${(@)^widget_dirs}"/*(N-.); do + zle -N -- "${f:t}" + done + + if [[ -z ${ZENO_ENABLE_FZF_TMUX-} ]]; then + export ZENO_FZF_COMMAND="fzf" + else + export ZENO_FZF_COMMAND="fzf-tmux" + fi + + export ZENO_ENABLE=1 + export ZENO_BOOTSTRAPPED=1 +} diff --git a/zeno.zsh b/zeno.zsh index ac949e0..b92d969 100644 --- a/zeno.zsh +++ b/zeno.zsh @@ -1,52 +1,5 @@ -if ! whence -p deno> /dev/null; then - return -fi - -export ZENO_ROOT=${ZENO_ROOT:-${0:a:h}} - -path+=${ZENO_ROOT}/bin - -() { - local widget_dirs=( - "${ZENO_ROOT}/shells/zsh/widgets" - ) - local autoload_dirs=( - "${ZENO_ROOT}/shells/zsh/functions" - "${(@)widget_dirs}" - ) - local f - - fpath+=("${(@)autoload_dirs}") - for f in "${(@)^autoload_dirs}"/*(N-.); autoload -Uz -- "${f:t}" - for f in "${(@)^widget_dirs}"/*(N-.); zle -N -- "${f:t}" -} - -if [[ -z $ZENO_ENABLE_FZF_TMUX ]]; then - export ZENO_FZF_COMMAND="fzf" -else - export ZENO_FZF_COMMAND="fzf-tmux" -fi +source "${${(%):-%N}:A:h}/zeno-bootstrap.zsh" -if [[ -z $ZENO_DISABLE_EXECUTE_CACHE_COMMAND ]]; then - command deno cache --node-modules-dir=auto --no-lock --no-check -- "${ZENO_ROOT}/src/cli.ts" +if (( $+functions[zeno-init] )); then + zeno-init fi - -if [[ -z $ZENO_DISABLE_SOCK ]]; then - printf -v DENO_VERSION '%d%02d%02d' ${(s:.:)$(deno -V)[2]} - if (( DENO_VERSION >= 11600 )); then - zeno-enable-sock - else - export ZENO_DISABLE_SOCK=1 - fi -fi - -if (( $+functions[zeno-history-hooks] )); then - zeno-history-hooks -fi - -if (( $+functions[zeno-preprompt-hooks] )); then - zeno-preprompt-hooks -fi - -export ZENO_ENABLE=1 -export ZENO_LOADED=1