Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 箇所にある
Expand Down
108 changes: 100 additions & 8 deletions e2e/tests/04_config.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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" {
Expand Down
19 changes: 19 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
91 changes: 72 additions & 19 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>: ` 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<T>(path: string, fallback: T, isValid: (given: unknown) => given is T): Promise<T> {
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<T>(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 {
Expand All @@ -32,28 +87,22 @@ 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<PartialContext>(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<Binding[]>(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), []).catch(() => [] as Binding[]),
loadYaml<Binding[]>(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))

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 = () => {
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions src/widget.eta
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down