diff --git a/CLAUDE.md b/CLAUDE.md index 742f299..a592478 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ zsh向けのwhich-keyライクメニュー。Deno製CLI (`src/wk.ts`) と、そ `wk run` はTUIを `/dev/tty` に描き、選ばれた結果だけを標準出力に出す。それを `_wk_widget` が読んで `BUFFER` へ差し込む。この受け渡しは `src/run.ts` と `src/widget.eta` の間の契約で、片方だけ変えると壊れる。 - 出力形式: 先頭1文字が区切り文字、以降はその区切り文字で連結された `buffer` + `key:value` 列 (`eval:true`, `accept:true` など)。区切り文字はbindingの `delimiter`、無ければconfigの `outputDelimiter`。 -- 終了コード: 0成功/3中断/4タイムアウト/5未定義キー/6キーパース失敗。`widget.eta` の `case` がこれで分岐し、それ以外は `zle -M` でエラー表示に回る。 +- 終了コード: 0成功/3中断/4タイムアウト/5未定義キー/6キーパース失敗/7設定ファイル不正。`widget.eta` の `case` がこれで分岐し、それ以外は `zle -M` でエラー表示に回る。 - エラー種別を増やすときは `src/errors.ts`・`run.ts` のcatch・`widget.eta` のcaseをセットで触る。 ## binding/config のスキーマは 3 箇所にある diff --git a/e2e/tests/04_config.bats b/e2e/tests/04_config.bats index d6b11b0..06b6d54 100644 --- a/e2e/tests/04_config.bats +++ b/e2e/tests/04_config.bats @@ -28,8 +28,7 @@ YAML assert_equal "$output" $'\t\tls -la' } -@test "a malformed bindings file is silently treated as empty" { - # Records the current behaviour; see the FIXME in run.ts. +@test "a malformed bindings file stops wk" { write_bindings <<'YAML' - key: l type: command @@ -38,12 +37,13 @@ YAML wk_run --inputs 'l' - assert_equal "$status" 5 - assert_equal "$stderr" '"l" is undefined' + assert_equal "$status" 7 + # The wording comes from the YAML parser, so only the shape wk adds is pinned. + assert_stderr_contains "${XDG_CONFIG_HOME}/wk/bindings.yaml: " + assert_stderr_contains 'at line 2, column 8' } -@test "a malformed config file falls back to the defaults" { - # Records the current behaviour; see the FIXME in run.ts. +@test "a malformed config file stops wk" { write_config <<'YAML' outputDelimiter: ', YAML @@ -55,8 +55,100 @@ YAML wk_run --inputs 'l' - assert_equal "$status" 0 - assert_equal "$output" $'\t\tls -la' + assert_equal "$status" 7 + assert_stderr_contains "${XDG_CONFIG_HOME}/wk/config.yaml: " + assert_stderr_contains 'at line 2, column 1' +} + +@test "a malformed local bindings file stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la +YAML + write_local_bindings <<'YAML' +- key: x + type: command +YAML + + # The local layer is not treated any more leniently than the global one. + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_stderr_contains "${PWD}/wk.bindings.yaml: " +} + +@test "a bindings file holding a mapping stops wk" { + write_bindings <<'YAML' +foo: bar +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: invalid format" +} + +@test "a bindings entry without a string key stops wk" { + write_bindings <<'YAML' +- desc: no key here + type: command + buffer: ls -la +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: invalid format" +} + +@test "a config file holding a scalar stops wk" { + write_config <<'YAML' +42 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: invalid format" +} + +@test "config is read before bindings, so the first broken file wins" { + write_config <<'YAML' +42 +YAML + write_bindings <<'YAML' +foo: bar +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: invalid format" +} + +@test "a directory in place of a config file stops wk" { + mkdir -p "${XDG_CONFIG_HOME}/wk/config.yaml" + + wk_run --inputs 'l' + + assert_equal "$status" 7 + # The reason is the operating system's own wording. + assert_stderr_contains "${XDG_CONFIG_HOME}/wk/config.yaml: " + assert_stderr_contains 'directory' +} + +@test "a path under HOME is reported with a tilde" { + mkdir -p "${HOME}/.config/wk" + cat >"${HOME}/.config/wk/bindings.yaml" <<'YAML' +foo: bar +YAML + unset XDG_CONFIG_HOME + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" '~/.config/wk/bindings.yaml: invalid format' } @test "global and local bindings are concatenated with global first" { diff --git a/src/errors.ts b/src/errors.ts index 9e56271..b0278b1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -27,3 +27,22 @@ export class KeyParseError extends Error { return this.#key.key } } + +export class ConfigError extends Error { + #path: string + #detail: string + + constructor(path: string, detail: string) { + super() + this.#path = path + this.#detail = detail + } + + getPath(): string { + return this.#path + } + + getDetail(): string { + return this.#detail + } +} diff --git a/src/run.ts b/src/run.ts index aedacd5..b5b51e7 100644 --- a/src/run.ts +++ b/src/run.ts @@ -7,13 +7,68 @@ import { TUI } from './tui.ts' import { defaultContext, mergeContext, PartialContext } from './types/Context.ts' import { Dependencies, main } from './main.ts' import { getKeySymbol, renderPrompt, renderTable } from './ui.ts' -import { AbortError, KeyParseError, UndefinedKeyError } from './errors.ts' +import { AbortError, ConfigError, KeyParseError, UndefinedKeyError } from './errors.ts' + +// `@std/yaml` reports `at line N, column M` on the first line, then an excerpt +// and a caret. Only the first line fits `zle -M`, and its trailing colon +// introduces the excerpt that is being dropped. +// +// Deno's IO errors append the syscall and the path (`... : readfile '/x'`), +// which the `: ` prefix already carries. +function summarize(e: unknown): string { + const message = e instanceof Error ? e.message : String(e) + return message.split('\n')[0].replace(/: \w+ '.*'$/, '').replace(/:$/, '') +} + +function isPartialContext(given: unknown): given is PartialContext { + return typeof given === 'object' && given !== null && !Array.isArray(given) +} + +function isBindings(given: unknown): given is Binding[] { + return Array.isArray(given) && + given.every((b) => typeof b === 'object' && b !== null && typeof (b as { key?: unknown }).key === 'string') +} + +// A missing file is the only silent fallback. Anything else — a syntax error, a +// shape mismatch, EACCES, EISDIR — stops wk, so that a typo cannot quietly +// change how it behaves. +async function loadYaml(path: string, fallback: T, isValid: (given: unknown) => given is T): Promise { + let text: string + try { + text = await Deno.readTextFile(path) + } catch (e: unknown) { + if (e instanceof Deno.errors.NotFound) { + return fallback + } + throw new ConfigError(path, summarize(e)) + } + + let parsed: unknown + try { + parsed = parseYaml(text) + } catch (e: unknown) { + throw new ConfigError(path, summarize(e)) + } -async function loadYaml(path: string, fallback: T) { - const text = await Deno.readTextFile(path) // An empty document — blank, comments only, `---`, `null`, `~` — parses to // null. Treat it exactly like an absent file. - return (parseYaml(text) ?? fallback) as T + if (parsed === null || parsed === undefined) { + return fallback + } + + if (!isValid(parsed)) { + throw new ConfigError(path, 'invalid format') + } + + return parsed +} + +function abbreviateHome(path: string): string { + const home = Deno.env.get('HOME') + if (home === undefined || home === '') { + return path + } + return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path } function unescapeAnsi(given: string): string { @@ -32,20 +87,14 @@ export const runCommand = new Command() For example, this simulates pressing "g", "p", and "f".`, ) .action(async ({ upOneLine, inputs }) => { - const fetchContextWaiting = (async () => { - // FIXME: a parse failure is swallowed here, which makes a typo in - // config.yaml indistinguishable from having no config.yaml. - const found = await loadYaml(joinPath(WK_CONFIG_HOME, 'config.yaml'), {}) - .catch(() => ({} as PartialContext)) - return mergeContext(found) - })() - - // FIXME: a parse failure is swallowed here, which makes a typo in - // bindings.yaml indistinguishable from having no bindings. - const fetchBindingsWaiting = Promise.all([ - loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), []).catch(() => [] as Binding[]), - loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), []).catch(() => [] as Binding[]), - ]).then(([globalBindings, localBindings]) => globalBindings.concat(localBindings)) + // Read in a fixed order and one at a time, so that the first broken file is + // the one reported and the rest are left untouched. + const load = async () => { + const ctx = mergeContext(await loadYaml(joinPath(WK_CONFIG_HOME, 'config.yaml'), {}, isPartialContext)) + const globalBindings = await loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), [], isBindings) + const localBindings = await loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), [], isBindings) + return [ctx, globalBindings.concat(localBindings)] as const + } const tty = await Deno.open('/dev/tty', { read: true, write: true }) const tui = new TUI(tty, inputs === undefined ? [] : inputs.split(' ').map(unescapeAnsi)) @@ -53,7 +102,7 @@ For example, this simulates pressing "g", "p", and "f".`, try { tui.init(upOneLine === true ? true : upOneLine === 'true' ? true : upOneLine === 'false' ? false : 'auto') - const [ctx, bindings] = await Promise.all([fetchContextWaiting, fetchBindingsWaiting]) + const [ctx, bindings] = await load() let timeoutTimerId: number | undefined const handleTimeout = () => { @@ -115,6 +164,10 @@ For example, this simulates pressing "g", "p", and "f".`, tui.close() console.error('Failed to parse key', e.getKey()) Deno.exit(6) + } else if (e instanceof ConfigError) { + tui.close() + console.error(`${abbreviateHome(e.getPath())}: ${e.getDetail()}`) + Deno.exit(7) } else { throw e } diff --git a/src/widget.eta b/src/widget.eta index 63cfbf7..1d2b8f6 100644 --- a/src/widget.eta +++ b/src/widget.eta @@ -22,6 +22,7 @@ _wk_widget() { # 2: Invalid arguments or options error # 5: No match error # 6: Key parse error + # 7: Config error # *: Unknown errors zle -M "wk: $res" return $wk_exit