diff --git a/.claude/skills/run-wk/SKILL.md b/.claude/skills/run-wk/SKILL.md index 461a7d4..3e6ec0a 100644 --- a/.claude/skills/run-wk/SKILL.md +++ b/.claude/skills/run-wk/SKILL.md @@ -41,9 +41,9 @@ stdout: \t\tgit push **1 つ目のフィールドは区切り文字そのもの**なので、buffer の前に区切り文字が 2 個並ぶ。`src/run.ts` が `[delimiter, buffer, ...].join(delimiter)` を出しており、1 個目はフィールドの中身、2 個目は join の区切り。受け側の `src/widget.eta` が `${res:2}` で捨てているのはこの 2 つ。末尾に改行が 1 個付く (`console.log` 由来、driver の `stdout:` 行では落としている)。 -**`key:value` の並びは YAML の記述順**。`src/run.ts` が `...rest` を `Object.entries()` で回すため、`accept` を先に書けば `accept:true` が先に出る。受け側が並び順を前提にすると壊れる。**`false` も省略されない** — 出るかどうかを決めるのは値ではなくキーが YAML にあるかどうかで、`eval: false` は `eval:false` として出る (キーごと省いた場合とは別物)。 +**`key:value` の並び順は保証しない。** `src/run.ts` は `...rest` を `Object.entries()` で回すが、その `rest` は zod が組み直したオブジェクトなので、schema が名前で持つ `eval` / `accept` が schema の順で先に出て、それ以外の追加フィールドがファイル順で続く (`accept` を先に書いても `eval:true accept:true` の順)。受け側は位置ではなく `key:` で引くこと — `src/widget.eta` の `reply[(rb:2:)eval:*]` がそうしている。**`false` も省略されない** — 出るかどうかを決めるのは値ではなくキーが YAML にあるかどうかで、`eval: false` は `eval:false` として出る (キーごと省いた場合とは別物)。 -**区切り文字は、その buffer に出てこない 1 文字にする。** 受け側の `${(@ps:$delimiter:)...}` は buffer 内の同じ文字も境界として split するので、既定のタブのままだと `buffer: "echo a\tb"` は `BUFFER=[echo a]` になる。バインディング単位の `delimiter` (`schemas/bindings.json`、1 文字) はこのためにあるが、**与えれば直るのではなく、衝突しない文字を選んで初めて直る** — 同じ buffer に `delimiter: 'b'` を与えると今度は `BUFFER=[echo a]` で切れる。config 全体の `outputDelimiter` ではなくバインディング単位で与えるのが正しい形。 +**区切り文字は、その buffer に出てこない 1 文字にする。** 受け側の `${(@ps:$delimiter:)...}` は buffer 内の同じ文字も境界として split するので、既定のタブのままだと `buffer: "echo a\tb"` は `BUFFER=[echo a]` になる。バインディング単位の `delimiter` (`src/schema.ts`、1 文字) はこのためにあるが、**与えれば直るのではなく、衝突しない文字を選んで初めて直る** — 同じ buffer に `delimiter: 'b'` を与えると今度は `BUFFER=[echo a]` で切れる。config 全体の `outputDelimiter` ではなくバインディング単位で与えるのが正しい形。 既定フィクスチャの区切り文字はタブなので、`\t\t` を見ても「1 つ目のフィールドだから 2 個」なのか「buffer にタブがある」のか区別できない。撃ち分けるには `delimiter: '|'` を持つバインディングを `$SP` に置く (`stdout: ||echo hi|eval:true` のように先頭 2 文字も追従する)。生バイトは `TMPDIR=$SP WK_KEEP=1` で残るサンドボックスの `out` / `err` / `status` で見る (パスは stderr に出る)。 @@ -72,7 +72,7 @@ $ WK_BINDINGS=e2e/fixtures/nested.bindings.yaml ./.claude/skills/run-wk/driver.s パンくずに並ぶのは desc ではなく**押したキー**。**`type: command` を選ぶと `wk run` が終了する = ペインも消える**ので、リーフまで降りると画面は残らない。これは成功で、出力は `keys` で見る。 -画面に出る記号はどれも `config.yaml` の `symbols` で差し替えられる (`src/types/Context.ts` の `defaultContext`)。 +画面に出る記号はどれも `config.yaml` の `symbols` で差し替えられる (`src/schema.ts` の `defaultContext`)。 | 画面上 | `symbols` のキー | 既定値 | 出る場所 | |---|---|---|---| @@ -88,7 +88,7 @@ $ WK_BINDINGS=e2e/fixtures/nested.bindings.yaml ./.claude/skills/run-wk/driver.s `timeout` のように**時間で消えるものは、キーごとの待ち (`WK_SETTLE`、既定 0.4 秒) より短ければ写らない。** 消えたのか出なかったのかは `keys` に流せば分かる (exit 4)。 -driver が 125 で止まる 2 つのメッセージは意味が正反対で、**ペインが生きているか**と所要時間で見分ける。`wk exited before drawing a menu` はペインが死んでいて即座 — wk のクラッシュか即終了で、原因は下の 3 つと**ポーリング粒度 0.05 秒より短い `timeout`** (この場合は一度描いてから消しているので字面と実態がずれる)。`menu never appeared` はペインが生きたまま 5 秒 — wk は正常にキー待ちで、`keys ` を送れば応答する。 +driver が 125 で止まる 2 つのメッセージは意味が正反対で、**ペインが生きているか**と所要時間で見分ける。`wk exited before drawing a menu` はペインが死んでいて即座 — wk のクラッシュか即終了で、原因は「弾かれる入力・落ちる入力」の節のものと**ポーリング粒度 0.05 秒より短い `timeout`** (この場合は一度描いてから消しているので字面と実態がずれる)。`menu never appeared` はペインが生きたまま 5 秒 — wk は正常にキー待ちで、`keys ` を送れば応答する。 ## widget — zsh の BUFFER まで @@ -128,13 +128,13 @@ $ WK_LEADER='^O' WK_PRETYPE=',' ./.claude/skills/run-wk/driver.sh widget t BUFFER=[make test] CURSOR=[9] ``` -## 落ちる入力 +## 弾かれる入力・落ちる入力 -どれも exit 1 で、`screen` では `wk exited before drawing a menu` に見える。 +**設定ファイルの不備は落ちずに exit 7 になる。** stderr は `<ファイル>: <パス>: <期待>` の 1 行で、パスが悪いフィールドまで案内する (`.../bindings.yaml: [0].bindings[0].key: expected a key name or a digit 0-9`)。`screen` からは `wk exited before drawing a menu` に見えるだけなので、**原因は `keys` に流して stderr を読む。** 配列でない `bindings.yaml`、`desc` の無い `type: bindings`、`bindings:` にスカラを置いたグループ、`buffer: 42`、`colors.prompt: {}`、`outputDelimiter: 42` — 描画時にスタックトレースを吐いていた入力は全部ここに畳まれている。グローバルとローカルで症状が変わることも無い。 -- **配列でない `bindings.yaml`** (スカラや mapping) — **グローバルとローカルで症状が違う。** `src/run.ts` は `globalBindings.concat(localBindings)` の形なので、グローバル側が配列でなければ `s.concat is not a function` で即死する。ローカル側は `concat()` の引数なので**落ちずに 1 要素として連結され**、描画時に `Cannot read properties of undefined (reading 'replace')` になる。パースは通るため `.catch()` はどちらでも発火しない。 -- **`desc` の無い `type: bindings`** — `Cannot read properties of undefined (reading 'replace')`。スキーマ違反として弾かれるのではなく**そのグループを描く瞬間に落ちる**ので、最上段なら起動直後、下の階層ならそこへ降りたとき。フィクスチャの必須フィールドは `key` / `type` / `buffer` の 3 つだけ (`schemas/bindings.json`) だが、グループの `desc` はこの通り実質必須。**この `reading 'replace'` は 1 つ上の「配列でないローカル `wk.bindings.yaml`」と同じ字面**なので、どちらか決め打ちせず両方のファイルを見る。 -- **`--inputs ' '`** — 空白 split の結果が空文字列 2 つになりキーコードパーサが落ちる。スペースキーは `\x20`。 +**`key` はクォート無しの数字でも通る。** YAML が数値にした `0`〜`9` は wk が文字列に戻す。**クォートが要るのは YAML のインジケータ文字だけ** — `.` / `$` / `(` / `)` / `+` / `/` / `;` / `<` / `=` / `\` / `^` / `_` はそのまま通る。クォート無しだと `~` / `!` / `?` / `#` は null になって wk が弾き、`"` / `%` / `&` / `'` / `*` / `,` / `-` / `:` / `@` / `[` / `]` / `` ` `` / `{` / `}` は YAML 側の構文エラー、`>` / `|` はブロックスカラ扱いで空文字列になって wk が弾く。どれも exit 7 だが、構文エラーの文言だけパーサ由来。 + +**exit 1 で落ちるのは `--inputs ' '` だけ。** 空白 split の結果が空文字列 2 つになりキーコードパーサが落ちる。スペースキーは `\x20`。 **空ファイルは落ちない。** 空ドキュメント (0 バイト・改行だけ・空白だけ・コメントだけ・`---`・`null`・`~`) は `src/run.ts` の `loadYaml()` が不在ファイルと同じ扱いに畳む。bindings なら全キー未定義 = exit 5、config なら既定値。 diff --git a/.claude/skills/run-wk/driver.sh b/.claude/skills/run-wk/driver.sh index 8f4faee..74d1fde 100755 --- a/.claude/skills/run-wk/driver.sh +++ b/.claude/skills/run-wk/driver.sh @@ -39,7 +39,7 @@ # WK_SETTLE seconds to wait after each key (default: 0.4 screen, 0.6 widget) # # Exit status: -# `keys` exits with wk's own exit code (0/1/2/3/4/5/6, or 124 when wk was +# `keys` exits with wk's own exit code (0/1/2/3/4/5/6/7, or 124 when wk was # still waiting for a key), so `driver.sh keys g p || ...` works from a # script. 1 is an uncaught error and is reachable from ordinary fixtures. # The other subcommands exit 0 on success. A driver-level failure — no binary, @@ -249,7 +249,7 @@ wait_for_screen() { # capture-pane strips trailing whitespace and tmux has already resolved the ANSI # colours away. The first line looks empty but is not: the default prompt symbol -# in src/types/Context.ts is a Nerd Font glyph (U+F460), which most terminals and +# in src/schema.ts is a Nerd Font glyph (U+F460), which most terminals and # every grep-based assertion render as nothing useful. Match on a binding row # instead of the prompt. # The widget layer's startup marker (below) is scaffolding, not evidence; drop @@ -375,12 +375,13 @@ cmd_keys() { explain_exit() { case "$1" in 0) echo '(selected a command)' ;; - 1) echo '(uncaught error — read stderr; a non-array bindings.yaml and a blank --inputs both land here)' ;; + 1) echo '(uncaught error — read stderr; a blank --inputs lands here)' ;; 2) echo '(bad CLI arguments)' ;; 3) echo '(abort: escape / ctrl-c / ctrl-d / backspace at root)' ;; 4) echo '(timeout — config.yaml timeout elapsed)' ;; 5) echo '(undefined key)' ;; 6) echo '(key parse failure)' ;; + 7) echo '(bad config.yaml or bindings.yaml — stderr names the file and the field)' ;; 124) echo '(driver timeout: wk was still waiting for a key)' ;; *) echo '(unknown)' ;; esac diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index e77b825..6243c74 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -52,6 +52,11 @@ jobs: - name: Run build run: mise run build + # Also exercises schema generation and packaging, so that a broken + # release tarball is caught before the tag is pushed. + - name: Run dist + run: mise run dist x86_64-unknown-linux-gnu + e2e: name: E2E runs-on: ubuntu-24.04 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5c4ba32..d82f808 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -36,14 +36,14 @@ jobs: - name: Setup project uses: ./.github/actions/setup-project - - name: Run build - run: VERSION=${{ github.ref_name }} mise run build:internal ${{ matrix.target }} + - name: Run dist + run: VERSION=${{ github.ref_name }} mise run dist ${{ matrix.target }} - - name: Upload binary + - name: Upload tarball uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: binary-${{ matrix.target }} - path: dist/wk-${{ matrix.target }} + name: tarball-${{ matrix.target }} + path: dist/wk-${{ matrix.target }}.tar.gz if-no-files-found: error retention-days: 1 @@ -84,10 +84,10 @@ jobs: permissions: contents: write steps: - - name: Download binaries + - name: Download tarballs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: binary-* + pattern: tarball-* path: dist merge-multiple: true @@ -97,6 +97,6 @@ jobs: name: release-notes - name: Create draft release - run: gh release create ${{ github.ref_name }} dist/wk-* --repo ${{ github.repository }} --draft --title ${{ github.ref_name }} --notes-file release-notes.md --verify-tag + run: gh release create ${{ github.ref_name }} dist/wk-*.tar.gz --repo ${{ github.repository }} --draft --title ${{ github.ref_name }} --notes-file release-notes.md --verify-tag env: GH_TOKEN: ${{ github.token }} diff --git a/CLAUDE.md b/CLAUDE.md index a592478..2932bd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,17 +10,19 @@ zsh向けのwhich-keyライクメニュー。Deno製CLI (`src/wk.ts`) と、そ - 終了コード: 0成功/3中断/4タイムアウト/5未定義キー/6キーパース失敗/7設定ファイル不正。`widget.eta` の `case` がこれで分岐し、それ以外は `zle -M` でエラー表示に回る。 - エラー種別を増やすときは `src/errors.ts`・`run.ts` のcatch・`widget.eta` のcaseをセットで触る。 -## binding/config のスキーマは 3 箇所にある +## スキーマは `src/schema.ts` が SSoT -`key`/`desc`/`buffer` などのフィールドを増減させたら、次の3箇所を揃える。 +`key`/`desc`/`buffer` などのフィールドを増減させたら、触るのは `src/schema.ts` と `README.md` の設定例だけ。 -- `src/types/Binding.ts`, `src/types/Context.ts` (型と既定値) -- `schemas/bindings.json`, `schemas/config.json` (ユーザー向けJSON Schema) -- `README.md` の設定例 +- `src/schema.ts` — `@zod/mini` のスキーマ。型 (`Binding`/`Command`/`Context`/`Color`) は `z.infer` で導出、既定値は `z._default`/`z.prefault` が持ち、`defaultContext` は `ContextSchema.parse({})`。手書きの型定義は無い。 +- 配布用JSON Schemaはコミットしていない。`mise run generate:schemas` が `dist/schemas/` に吐き、`mise run dist ` がリリースtarball (`wk-/{wk,LICENSE,schemas/}`) に詰める。生成は `io: 'input'` — ユーザーが書ける形を出す。 +- `@zod/mini` はロケールを積まないので、`error` を渡し忘れた検査は `Invalid input` になる。フィールドを足したら必ず `expected(...)` を渡し、既存の入力を一通り流して `Invalid input` が出ないことを見る。 +- 設定エラーは `<ファイル>: <パス>: <期待>` の1行で exit 7。パスはzodのissueの `path` から `[0].bindings[0].key` の形に組む。 +- `key` は文字列だが、YAMLがクォート無しの数字を数値にするため `0`〜`9` の整数も受けて `String()` で正規化する。クォートが要るのはYAMLのインジケータ文字だけ (nullになるか構文エラー) で、`.` のような大半の記号はそのまま通る。 ## 検証 -静的チェックは `mise run check` にまとまっている。`VERSION` はgitignoreされた生成物で `wk.ts` がraw-importしているため、素の `deno check src/wk.ts` は `TS2307` で落ちる。型チェックは `mise run check:type` 経由で走らせる。依存の挙動を単発スクリプトで確かめるときは `deno run --config deno.jsonc ` — import map がここにあるので、渡さないと `@cliffy/*` が解決できずに落ちる。`src/main.ts` はキー入力ループを `Dependencies` で注入する形になっているが、まだ差し替え先が存在しない。 +静的チェックは `mise run check` にまとまっている。`VERSION` はgitignoreされた生成物で `wk.ts` がraw-importしているため、素の `deno check src/wk.ts` は `TS2307` で落ちる。型チェックは `mise run check:type` 経由で走らせる。依存の挙動を単発スクリプトで確かめるときは `deno run --config deno.jsonc ` — import map がここにあるので、渡さないと `@cliffy/*` や `@zod/mini` が解決できずに落ちる。`deno.jsonc` は `lock.frozen` なので、依存を足したら `deno install --frozen=false`。`src/main.ts` はキー入力ループを `Dependencies` で注入する形になっているが、まだ差し替え先が存在しない。 e2eテストが `e2e/` にある。`mise run e2e` でバイナリをビルドしてから走らせ、`mise run e2e:only` は `dist/` の既存バイナリをそのまま使う (パスを渡せば1ファイルだけ — `mise run e2e:only e2e/tests/06_widget.bats`)。`exec format error` は `WK_E2E_TARGET` とバイナリのアーキ不一致。Docker (zsh/tmux/bats-core、Denoは入れない) の中でbats-coreを回し、対象バイナリは `WK_BIN` で受け取る黒箱テスト。将来Denoをやめても受け入れ仕様として使い回せるよう、テスト側からDenoを参照しないこと。層は3つ — `script(1)` でptyを張ってCLIを直叩き (`helpers/common.bash` の `wk_run`)、TTY不要の `wk init`、tmuxで実zshウィジェットを動かす (`helpers/tmux.bash`)。ユニットテストは無い。 diff --git a/README.md b/README.md index d8c78ef..1cf9b75 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,23 @@ ## :package: Installation -1. Download the latest release and put into your `$PATH`: +1. Download the archive for your platform from the latest release and extract it: -2. Activate in `$ZDOTDIR/.zshrc`: + ```shell + tar xzf wk-x86_64-unknown-linux-gnu.tar.gz + ``` + + It holds the `wk` binary, the JSON Schemas for the configuration files, and the license. + +2. Put the binary into your `$PATH`: + + ```shell + install -m 755 wk-x86_64-unknown-linux-gnu/wk ~/.local/bin/ + ``` + +3. Activate in `$ZDOTDIR/.zshrc`: ```shell # Bind space as the leader key and comma as the major-leader key. @@ -19,7 +31,7 @@ eval "$(wk init --leader ' ' --major-leader ',' --major-prefix 'm')" ``` -3. Restart zsh. +4. Restart zsh. > [!TIP] > If you want to register only the widgets, change it as follows: @@ -59,7 +71,11 @@ colors: bindingDescription: 8 ``` -See [schemas/config.json](./schemas/config.json) for more details. +Point your editor at the schema in the extracted archive to get completion and validation: + +```yaml +# yaml-language-server: $schema=/path/to/wk-x86_64-unknown-linux-gnu/schemas/config.json +``` ### Global bindings @@ -100,7 +116,16 @@ See [schemas/config.json](./schemas/config.json) for more details. accept: true ``` -See [schemas/bindings.json](./schemas/bindings.json) for more details. +Point your editor at the schema in the extracted archive to get completion and validation: + +```yaml +# yaml-language-server: $schema=/path/to/wk-x86_64-unknown-linux-gnu/schemas/bindings.json +``` + +A `key` is the key to press. YAML reads an unquoted digit as a number, and wk reads it back as that +digit, so `key: 1` and `key: '1'` both bind the `1` key. Most punctuation needs no quoting either — +`key: .` above is one. YAML's indicator characters do, though: unquoted they are read as null or +rejected outright, so write `key: '~'` and `key: ':'`. ### Local bindings diff --git a/deno.jsonc b/deno.jsonc index 8fdd628..4e5a0f3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -24,6 +24,7 @@ "@std/fmt": "jsr:@std/fmt@1.0.3", "@std/path": "jsr:@std/path@1.0.8", "@std/yaml": "jsr:@std/yaml@1.0.5", + "@zod/mini": "jsr:@zod/zod@4.5.4/mini", "xdg-basedir": "npm:xdg-basedir@5.1.0" }, "unstable": ["raw-imports"] diff --git a/deno.lock b/deno.lock index 2573d23..2bd741f 100644 --- a/deno.lock +++ b/deno.lock @@ -16,6 +16,7 @@ "jsr:@std/path@1.0.8": "1.0.8", "jsr:@std/text@~1.0.7": "1.0.15", "jsr:@std/yaml@1.0.5": "1.0.5", + "jsr:@zod/zod@4.5.4": "4.5.4", "npm:@types/node@*": "22.13.8", "npm:xdg-basedir@5.1.0": "5.1.0" }, @@ -84,6 +85,9 @@ }, "@std/yaml@1.0.5": { "integrity": "71ba3d334305ee2149391931508b2c293a8490f94a337eef3a09cade1a2a2742" + }, + "@zod/zod@4.5.4": { + "integrity": "bf0da80e42232d5f9f485d37b5c980e0d88f07fe04b9b1f2bd9012c008404df4" } }, "npm": { @@ -111,6 +115,7 @@ "jsr:@std/fmt@1.0.3", "jsr:@std/path@1.0.8", "jsr:@std/yaml@1.0.5", + "jsr:@zod/zod@4.5.4", "npm:xdg-basedir@5.1.0" ] } diff --git a/e2e/tests/01_protocol.bats b/e2e/tests/01_protocol.bats index f53aa3b..88985f9 100644 --- a/e2e/tests/01_protocol.bats +++ b/e2e/tests/01_protocol.bats @@ -113,24 +113,6 @@ YAML assert_equal "$output" $'\t\tls -la\tnote:hello world' } -@test "non-scalar fields are dropped" { - write_bindings <<'YAML' -- key: l - type: command - buffer: ls -la - num: 1 - arr: [a, b] - obj: { a: 1 } - str: kept -YAML - - wk_run --inputs 'l' - - assert_equal "$status" 0 - # run.ts only forwards string and boolean values. - assert_equal "$output" $'\t\tls -la\tstr:kept' -} - @test "an empty buffer still produces the two leading delimiters" { write_bindings <<'YAML' - key: l diff --git a/e2e/tests/04_config.bats b/e2e/tests/04_config.bats index 06b6d54..af03958 100644 --- a/e2e/tests/04_config.bats +++ b/e2e/tests/04_config.bats @@ -86,20 +86,25 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: invalid format" + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: expected a list of bindings" } -@test "a bindings entry without a string key stops wk" { +@test "a bindings entry without a key stops wk, naming the entry" { write_bindings <<'YAML' +- desc: fine + key: a + type: command + buffer: ls -la - desc: no key here type: command buffer: ls -la YAML - wk_run --inputs 'l' + wk_run --inputs 'a' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: invalid format" + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [1].key: expected a key name or a digit 0-9" } @test "a config file holding a scalar stops wk" { @@ -110,7 +115,7 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: invalid format" + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: expected a mapping" } @test "config is read before bindings, so the first broken file wins" { @@ -124,7 +129,7 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: invalid format" + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: expected a mapping" } @test "a directory in place of a config file stops wk" { @@ -148,7 +153,7 @@ YAML wk_run --inputs 'l' assert_equal "$status" 7 - assert_equal "$stderr" '~/.config/wk/bindings.yaml: invalid format' + assert_equal "$stderr" '~/.config/wk/bindings.yaml: expected a list of bindings' } @test "global and local bindings are concatenated with global first" { diff --git a/e2e/tests/09_validation.bats b/e2e/tests/09_validation.bats new file mode 100644 index 0000000..faccbb1 --- /dev/null +++ b/e2e/tests/09_validation.bats @@ -0,0 +1,322 @@ +#!/usr/bin/env bats +# +# What a field may hold. Every rejection is exit 7 with one line naming the path +# that failed, so that a bad field never reaches the drawing code. + +setup() { + load '../helpers/common' + setup_wk_env +} + +# A binding that is fine on its own, so that a config-only test still has +# something to press. +write_valid_bindings() { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la +YAML +} + +# --- config --------------------------------------------------------------- + +@test "a non-string outputDelimiter stops wk" { + write_valid_bindings + write_config <<'YAML' +outputDelimiter: 42 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/config.yaml: outputDelimiter: expected a single character" +} + +@test "a multi-character outputDelimiter stops wk" { + write_valid_bindings + write_config <<'YAML' +outputDelimiter: '::' +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/config.yaml: outputDelimiter: expected a single character" +} + +@test "a non-numeric timeout stops wk rather than silently never firing" { + write_valid_bindings + write_config <<'YAML' +timeout: 5s +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/config.yaml: timeout: expected a whole number of milliseconds" +} + +@test "a malformed colour stops wk instead of crashing the drawing code" { + write_valid_bindings + write_config <<'YAML' +colors: + prompt: {} +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: colors.prompt: expected a color" +} + +@test "a colour outside the ANSI range stops wk" { + write_valid_bindings + write_config <<'YAML' +colors: + prompt: 999 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/config.yaml: colors.prompt: expected a color" +} + +@test "an unknown config field is left alone" { + write_valid_bindings + # The schema puts no ceiling on the top level, so a field wk does not know is + # not wk's business. + write_config <<'YAML' +unknownField: whatever +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 0 + assert_equal "$output" $'\t\tls -la' +} + +# --- bindings ------------------------------------------------------------- + +@test "a container written with nothing under it falls back to the defaults" { + write_valid_bindings + # Commenting out every entry under `colors:` leaves YAML null, which means the + # same as never having written the key. + write_config <<'YAML' +timeout: 500 +colors: + # bindingKey: 4 +symbols: + keys: +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 0 + assert_equal "$output" $'\t\tls -la' +} + +@test "an unquoted digit is accepted as a key" { + # YAML reads it as a number; wk reads it back as the digit that was typed. + write_bindings <<'YAML' +- key: 1 + type: command + buffer: first +YAML + + wk_run --inputs '1' + + assert_equal "$status" 0 + assert_equal "$output" $'\t\tfirst' +} + +@test "a binding without a type stops wk" { + write_bindings <<'YAML' +- key: l + buffer: ls -la +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].type: expected type \"command\" or \"bindings\"" +} + +@test "a non-string buffer stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: 42 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].buffer: expected a string" +} + +@test "an empty binding delimiter stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + delimiter: '' +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].delimiter: expected a single character" +} + +@test "a non-string extra field on a command stops wk" { + # The extra fields become the `key:value` pairs of the output protocol, which + # only carries strings and booleans. + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + note: 1 +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].note: expected a string or a boolean" +} + +@test "a list-valued extra field on a command stops wk" { + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + arr: [a, b] +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].arr: expected a string or a boolean" +} + +@test "a non-boolean eval stops wk" { + # `eval` and `accept` are named in the schema rather than left to the catchall, + # because the widget only acts on the literal `true`. + write_bindings <<'YAML' +- key: l + type: command + buffer: ls -la + eval: 'yes' +YAML + + wk_run --inputs 'l' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].eval: expected a boolean" +} + +@test "a group without a description stops wk" { + write_bindings <<'YAML' +- key: g + type: bindings + bindings: + - key: p + type: command + buffer: git push +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].desc: expected a string" +} + +@test "a group whose bindings are not a list stops wk" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + bindings: oops +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].bindings: expected a list of bindings" +} + +@test "an unknown field on a group stops wk, naming the field" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + oops: 1 + bindings: + - key: p + type: command + buffer: git push +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0]: unknown field \"oops\"" +} + +@test "a nested binding is checked too, and the path leads to it" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + bindings: + - key: p + desc: Push/Pull + type: bindings + bindings: + - key: {} + type: command + buffer: git push +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].bindings[0].bindings[0].key: expected a key name or a digit 0-9" +} + +@test "an empty group stops wk" { + write_bindings <<'YAML' +- key: g + desc: Git + type: bindings + bindings: [] +YAML + + wk_run --inputs 'g' + + assert_equal "$status" 7 + assert_equal "$stderr" \ + "${XDG_CONFIG_HOME}/wk/bindings.yaml: [0].bindings: expected at least one binding" +} + +@test "a local bindings file is validated the same way" { + write_local_bindings <<'YAML' +- key: x + type: command + buffer: 42 +YAML + + wk_run --inputs 'x' + + assert_equal "$status" 7 + assert_equal "$stderr" "${PWD}/wk.bindings.yaml: [0].buffer: expected a string" +} diff --git a/mise.toml b/mise.toml index f6f1071..651ca36 100644 --- a/mise.toml +++ b/mise.toml @@ -17,7 +17,7 @@ run = 'deno lint' [tasks.'check:type'] depends = 'generate:version' -run = 'deno check src/wk.ts' +run = 'deno check src/wk.ts scripts/generate-schemas.ts' [tasks.'generate:version'] run = 'echo "${VERSION:-unknown}" > VERSION' @@ -35,6 +35,27 @@ depends = 'bundle' run = 'deno compile --allow-all --target {{arg(name="target")}} --output dist/wk-{{arg(name="target")}} dist/wk.bundle.js' hide = true +[tasks.'generate:schemas'] +description = 'Generate the distributable JSON Schemas from src/schema.ts' +run = 'deno run --allow-write=dist scripts/generate-schemas.ts' + +[tasks.dist] +description = 'Package a target into dist/wk-.tar.gz with its schemas and LICENSE' +depends = 'generate:schemas' +run = ''' +set -eu +target='{{arg(name="target")}}' +mise run build:internal "${target}" +stage="dist/pkg/wk-${target}" +rm -rf "${stage}" +mkdir -p "${stage}" +cp "dist/wk-${target}" "${stage}/wk" +cp LICENSE "${stage}/" +cp -R dist/schemas "${stage}/schemas" +tar -czf "dist/wk-${target}.tar.gz" -C dist/pkg "wk-${target}" +rm -rf dist/pkg +''' + [tasks.bundle] depends = 'generate:version' run = 'deno bundle --minify --output dist/wk.bundle.js src/wk.ts' diff --git a/schemas/bindings.json b/schemas/bindings.json deleted file mode 100644 index 313c457..0000000 --- a/schemas/bindings.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Bindings" - }, - { - "$ref": "#/definitions/Command" - } - ] - }, - "minItems": 1, - "definitions": { - "Command": { - "type": "object", - "required": ["key", "type", "buffer"], - "properties": { - "key": { - "type": "string", - "minLength": 1 - }, - "desc": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["command"] - }, - "buffer": { - "type": "string" - }, - "delimiter": { - "type": "string", - "minLength": 1, - "maxLength": 1 - }, - "eval": { - "type": "boolean" - }, - "accept": { - "type": "boolean" - } - }, - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "boolean" - } - ] - } - }, - "Bindings": { - "type": "object", - "required": ["key", "desc", "type", "bindings"], - "additionalProperties": false, - "properties": { - "key": { - "type": "string", - "minLength": 1 - }, - "desc": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["bindings"] - }, - "bindings": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Bindings" - }, - { - "$ref": "#/definitions/Command" - } - ] - }, - "minItems": 1 - } - } - } - } -} diff --git a/schemas/config.json b/schemas/config.json deleted file mode 100644 index 929b860..0000000 --- a/schemas/config.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "type": "object", - "properties": { - "outputDelimiter": { - "type": "string", - "minLength": 1, - "maxLength": 1 - }, - "timeout": { - "type": "integer", - "minimum": 0 - }, - "symbols": { - "type": "object", - "properties": { - "prompt": { - "type": "string" - }, - "breadcrumb": { - "type": "string" - }, - "separator": { - "type": "string" - }, - "group": { - "type": "string" - }, - "keys": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "colors": { - "type": "object", - "properties": { - "prompt": { - "$ref": "#/definitions/Color" - }, - "breadcrumb": { - "$ref": "#/definitions/Color" - }, - "separator": { - "$ref": "#/definitions/Color" - }, - "group": { - "$ref": "#/definitions/Color" - }, - "inputKeys": { - "$ref": "#/definitions/Color" - }, - "lastInputKey": { - "$ref": "#/definitions/Color" - }, - "bindingKey": { - "$ref": "#/definitions/Color" - }, - "bindingIcon": { - "$ref": "#/definitions/Color" - }, - "bindingDescription": { - "$ref": "#/definitions/Color" - } - } - } - }, - "definitions": { - "Color": { - "anyOf": [ - { - "$ref": "#/definitions/ANSIColor" - }, - { - "type": "object", - "properties": { - "color": { - "$ref": "#/definitions/ANSIColor" - }, - "attrs": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "bold", - "dim", - "italic", - "underline", - "inverse", - "hidden", - "strikethrough" - ] - }, - "minItems": 1 - } - }, - "required": [ - "color", - "attrs" - ] - } - ] - }, - "ANSIColor": { - "anyOf": [ - { - "type": "integer", - "minimum": -1, - "maximum": 255 - }, - { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - } - } -} diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts new file mode 100644 index 0000000..8f2ab7e --- /dev/null +++ b/scripts/generate-schemas.ts @@ -0,0 +1,39 @@ +import * as z from '@zod/mini' +import { BindingsSchema, ContextSchema, nullableInputs } from '../src/schema.ts' + +// These land in the release tarball as `schemas/`, for editors to validate a +// `bindings.yaml` or a `config.yaml` against. They are generated rather than +// committed so that they cannot drift from what wk itself enforces. +// +// `io: 'input'` describes what a user may write, which is what an editor needs: +// the output side has already had defaults filled in and `key` normalised. +const MAX_SAFE = Number.MAX_SAFE_INTEGER + +const options = { + io: 'input', + override: ( + { zodSchema, jsonSchema }: { zodSchema: z.core.$ZodType; jsonSchema: Record }, + ) => { + // zod stamps the registry id it used to name the `$defs` entry, and spells + // out the safe-integer bounds of every `z.int()`. Neither is worth + // publishing. + delete jsonSchema.id + if (jsonSchema.maximum === MAX_SAFE) delete jsonSchema.maximum + if (jsonSchema.minimum === -MAX_SAFE) delete jsonSchema.minimum + // `colors:`/`symbols:` written with nothing under them are an absence wk + // honours, but zod describes these wrappers by their output side alone. + if (nullableInputs.has(zodSchema)) jsonSchema.type = [jsonSchema.type, 'null'] + }, +} as const + +// Cleared first, so that a schema that was renamed or removed cannot ride +// along in the next tarball. +try { + Deno.removeSync('dist/schemas', { recursive: true }) +} catch { /* nothing to clear */ } +Deno.mkdirSync('dist/schemas', { recursive: true }) +for (const [name, schema] of [['bindings', BindingsSchema], ['config', ContextSchema]] as const) { + const path = `dist/schemas/${name}.json` + Deno.writeTextFileSync(path, `${JSON.stringify(z.toJSONSchema(schema, options), null, 2)}\n`) + console.log(`generated: ${path}`) +} diff --git a/src/main.ts b/src/main.ts index 23cc55c..36a1f3e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { type KeyPressEvent } from '@cliffy/keypress' import { PRINTABLE_ASCII } from './const.ts' import { AbortError, KeyParseError, UndefinedKeyError } from './errors.ts' -import { type Binding, type Command } from './types/Binding.ts' +import { type Binding, type Command } from './schema.ts' export type Dependencies = { keypress: () => AsyncIterable diff --git a/src/run.ts b/src/run.ts index b5b51e7..c51a117 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,10 +1,9 @@ import { Command, EnumType } from '@cliffy/command' import { join as joinPath } from '@std/path' import { parse as parseYaml } from '@std/yaml' -import { Binding } from './types/Binding.ts' import { WK_CONFIG_HOME } from './const.ts' import { TUI } from './tui.ts' -import { defaultContext, mergeContext, PartialContext } from './types/Context.ts' +import type { Binding, ParseResult } from './schema.ts' import { Dependencies, main } from './main.ts' import { getKeySymbol, renderPrompt, renderTable } from './ui.ts' import { AbortError, ConfigError, KeyParseError, UndefinedKeyError } from './errors.ts' @@ -20,19 +19,10 @@ function summarize(e: unknown): string { 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 { +async function loadYaml(path: string, fallback: T, parse: (given: unknown) => ParseResult): Promise { let text: string try { text = await Deno.readTextFile(path) @@ -56,11 +46,12 @@ async function loadYaml(path: string, fallback: T, isValid: (given: unknown) return fallback } - if (!isValid(parsed)) { - throw new ConfigError(path, 'invalid format') + const result = parse(parsed) + if (!result.ok) { + throw new ConfigError(path, result.reason) } - return parsed + return result.value } function abbreviateHome(path: string): string { @@ -89,10 +80,16 @@ For example, this simulates pressing "g", "p", and "f".`, .action(async ({ upOneLine, inputs }) => { // 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. + // Loaded here rather than at the top of the file: pulling in the schema + // costs a few milliseconds, and `wk init` — which runs from `.zshrc` on + // every new shell — has no configuration to validate. + const { defaultContext, parseBindings, parseContext } = await import('./schema.ts') + 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) + const ctx = await loadYaml(joinPath(WK_CONFIG_HOME, 'config.yaml'), defaultContext, parseContext) + const empty: Binding[] = [] + const globalBindings = await loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), empty, parseBindings) + const localBindings = await loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), empty, parseBindings) return [ctx, globalBindings.concat(localBindings)] as const } @@ -137,18 +134,11 @@ For example, this simulates pressing "g", "p", and "f".`, const delimiter = typeof definedDelimiter === 'string' ? definedDelimiter : ctx.outputDelimiter + // The schema has already narrowed every extra field to a string or a + // boolean, and a boolean interpolates as `true` / `false` on its own. const outputs = [delimiter, buffer] for (const [k, v] of Object.entries(rest)) { - switch (typeof v) { - case 'string': - outputs.push(`${k}:${v}`) - break - case 'boolean': - outputs.push(`${k}:${v ? 'true' : 'false'}`) - break - default: - break - } + outputs.push(`${k}:${v}`) } console.log(outputs.join(delimiter)) diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..fa8a356 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,245 @@ +import * as z from '@zod/mini' + +// The single source of truth for what a `config.yaml` and a `bindings.yaml` may +// contain. `schemas/*.json` in a release tarball is generated from here by +// `mise run generate:schemas`, and the types below are inferred from it, so +// there is only one place to change when a field is added or removed. +// +// Every message is the expectation on its own, because `run.ts` prints it after +// the path that located it: `: : expected ...`. `@zod/mini` ships no +// locale, so an expectation that is not spelled out here comes out as a bare +// `Invalid input`. +function expected(what: string) { + return { error: `expected ${what}` } +} + +function singleCharacter() { + const params = expected('a single character') + return z.string(params).check(z.length(1, params)) +} + +const aString = () => z.string(expected('a string')) + +// ---------------------------------------------------------------- bindings + +// A key is a string, but YAML turns an unquoted digit into a number and `1` is +// a natural thing to bind, so take those too and normalise them back. The +// punctuation that YAML reads as null (`~`, `!`, `?`, `#`) cannot be recovered +// this way and still has to be quoted. +const aKey = expected('a key name or a digit 0-9') + +const KeySchema = z.union([ + z.string().check(z.minLength(1, aKey)), + z.pipe(z.int().check(z.minimum(0), z.maximum(9)), z.transform(String)), +], aKey) + +// The extra keys are the `key:value` pairs of the output protocol, so anything +// the widget can carry — a string or a boolean — is allowed through. +const CommandSchema = z.catchall( + z.object({ + key: KeySchema, + desc: z.optional(aString()), + icon: z.optional(aString()), + type: z.literal('command'), + buffer: aString(), + delimiter: z.optional(singleCharacter()), + // Named rather than left to the catchall, so that the published schema can + // offer the two flags `widget.eta` actually acts on. + eval: z.optional(z.boolean(expected('a boolean'))), + accept: z.optional(z.boolean(expected('a boolean'))), + }), + z.union([z.string(), z.boolean()], expected('a string or a boolean')), +) + +const GroupSchema = z.strictObject({ + key: KeySchema, + // Unlike a command, a group has no buffer to fall back on when drawing. + desc: aString(), + icon: z.optional(aString()), + type: z.literal('bindings'), + get bindings() { + return z.array(BindingSchema, expected('a list of bindings')) + .check(z.minLength(1, expected('at least one binding'))) + }, +}, { + error: (issue) => + issue.code === 'unrecognized_keys' + ? `unknown field ${issue.keys.map((key) => `"${key}"`).join(', ')}` + : 'expected a group', +}) + +const BindingSchema: z.ZodMiniType = z.discriminatedUnion( + 'type', + [CommandSchema, GroupSchema], + expected('type "command" or "bindings"'), +) +z.globalRegistry.add(BindingSchema, { id: 'Binding' }) + +export const BindingsSchema = z.array(BindingSchema, expected('a list of bindings')) + +export type Command = z.infer +export type Group = Omit, 'bindings'> & { bindings: Binding[] } +export type Binding = Command | Group + +// ---------------------------------------------------------------- config + +// Every branch reports the same expectation, so that a number out of range +// reads as a bad colour rather than as a failed bound. +const aColor = expected('a color') + +const ANSI_COLOR = [ + z.int(aColor).check(z.minimum(-1, aColor), z.maximum(255, aColor)), + z.string(aColor).check(z.regex(/^#[0-9a-fA-F]{6}$/, aColor)), +] as const + +const ColorSchema = z.union([ + ...ANSI_COLOR, + z.object({ + color: z.union([...ANSI_COLOR], aColor), + attrs: z.array( + z.enum(['bold', 'dim', 'italic', 'underline', 'inverse', 'hidden', 'strikethrough']), + expected('a list of attributes'), + ).check(z.minLength(1, expected('at least one attribute'))), + }), +], aColor) +z.globalRegistry.add(ColorSchema, { id: 'Color' }) + +const aMapping = expected('a mapping') + +// YAML reads `colors:` with nothing under it as null, which means the same as +// not writing the key at all — the fold `loadYaml` already applies to a whole +// document that parses to null. Only the containers get it: an empty +// `outputDelimiter:` is a value that cannot work, not an absence. +// +// The casts are only about `z.transform` widening its output past `schema`'s +// input. The pipe hands the value straight through, so the wrapper keeps +// `schema`'s own type, including whether the field is optional. +// +// A pipe that starts with a transform has no input schema to describe, so +// `z.toJSONSchema` falls back to the output side and the published schema would +// say `null` is invalid. `nullableInputs` marks the wrappers so that +// `generate-schemas.ts` can put it back; keeping the union out of the runtime +// schema is what keeps a bad field reported as `colors.prompt: expected a +// color` instead of collapsing to `colors: expected a mapping`. +export const nullableInputs = z.registry>() + +function orAbsent(schema: T): T { + const wrapper = z.pipe( + z.transform((given: unknown) => given ?? undefined), + schema as unknown as z.ZodMiniType | undefined>, + ) as unknown as T + nullableInputs.add(wrapper, {}) + return wrapper +} + +const DEFAULT_KEY_SYMBOLS: Record = { + space: '␣', + return: '⏎', + tab: '⇥', + up: '↑', + down: '↓', + right: '→', + left: '←', + home: '⇱', + end: '⇲', + pageup: '⇞', + pagedown: '⇟', + insert: '⎀', + delete: '⌦', + F1: '󱊫', + F2: '󱊬', + F3: '󱊭', + F4: '󱊮', + F5: '󱊯', + F6: '󱊰', + F7: '󱊱', + F8: '󱊲', + F9: '󱊳', + F10: '󱊴', + F11: '󱊵', + F12: '󱊶', +} + +export const ContextSchema = z.looseObject({ + outputDelimiter: z._default(singleCharacter(), '\t'), + // Capped because a delay past 2^31-1 wraps around in V8 and fires almost + // immediately, which looks like the menu closing on its own. + timeout: z._default( + z.int(expected('a whole number of milliseconds')).check( + z.minimum(0, expected('0 or more')), + z.maximum(300_000, expected('300000 (5 minutes) or less')), + ), + 0, + ), + symbols: orAbsent( + z.prefault( + z.looseObject({ + // U+F460 is a Nerd Font glyph; spelled out so that it survives editing. + prompt: z._default(aString(), '\uF460 '), + breadcrumb: z._default(aString(), ' » '), + separator: z._default(aString(), '➜'), + group: z._default(aString(), '+'), + // Merged into the built-in symbols rather than replacing them, so that + // naming one key does not blank out the rest. + keys: orAbsent( + z.pipe( + z.optional(z.record(z.string(), aString(), expected('a mapping of key names to symbols'))), + z.transform((given) => ({ ...DEFAULT_KEY_SYMBOLS, ...given })), + ), + ), + }, aMapping), + {}, + ), + ), + colors: orAbsent( + z.prefault( + z.looseObject({ + prompt: z._default(ColorSchema, 8), + breadcrumb: z._default(ColorSchema, { color: 8, attrs: ['dim'] }), + separator: z._default(ColorSchema, { color: 8, attrs: ['dim'] }), + group: z._default(ColorSchema, 8), + inputKeys: z._default(ColorSchema, 8), + lastInputKey: z._default(ColorSchema, -1), + bindingKey: z._default(ColorSchema, -1), + bindingIcon: z._default(ColorSchema, 8), + bindingDescription: z._default(ColorSchema, 8), + }, aMapping), + {}, + ), + ), +}, aMapping) + +export type Color = z.infer +export type Context = z.infer + +export const defaultContext: Context = ContextSchema.parse({}) + +// ---------------------------------------------------------------- parsing + +export type ParseResult = { ok: true; value: T } | { ok: false; reason: string } + +// The first issue is the one reported: only a single line fits `zle -M`. +function toReason(error: { issues: readonly { path: PropertyKey[]; message: string }[] }): string { + const issue = error.issues[0] + const path = issue.path + .map((segment) => typeof segment === 'number' ? `[${segment}]` : `.${String(segment)}`) + .join('') + .replace(/^\./, '') + return path === '' ? issue.message : `${path}: ${issue.message}` +} + +export function parseBindings(given: unknown): ParseResult { + const result = BindingsSchema.safeParse(given) + if (!result.success) { + return { ok: false, reason: toReason(result.error) } + } + // `BindingSchema` is annotated as an opaque `ZodMiniType` to break the + // recursion between a group and its own items, so what comes back is untyped + // even though it matched. + return { ok: true, value: result.data as Binding[] } +} + +export function parseContext(given: unknown): ParseResult { + const result = ContextSchema.safeParse(given) + return result.success ? { ok: true, value: result.data } : { ok: false, reason: toReason(result.error) } +} diff --git a/src/types/Binding.ts b/src/types/Binding.ts deleted file mode 100644 index d0970c6..0000000 --- a/src/types/Binding.ts +++ /dev/null @@ -1,21 +0,0 @@ -type Base = { - key: string - desc?: string - icon?: string -} - -export type Command = Base & { - type: 'command' - buffer: string - [key: string]: string | boolean -} - -type Bindings = Base & { - type: 'bindings' - desc: string - bindings: TmpBinding[] -} - -type TmpBinding = Command | Bindings - -export type { TmpBinding as Binding } diff --git a/src/types/Context.ts b/src/types/Context.ts deleted file mode 100644 index 2442e16..0000000 --- a/src/types/Context.ts +++ /dev/null @@ -1,125 +0,0 @@ -type ANSIColor = number | string - -type ColorAttribute = 'bold' | 'dim' | 'italic' | 'underline' | 'inverse' | 'hidden' | 'strikethrough' - -export type Color = ANSIColor | { color: ANSIColor; attrs: [ColorAttribute, ...ColorAttribute[]] } - -export type Context = { - outputDelimiter: string - timeout: number - symbols: { - prompt: string - breadcrumb: string - separator: string - group: string - keys: Record - } - colors: { - prompt: Color - breadcrumb: Color - separator: Color - group: Color - inputKeys: Color - lastInputKey: Color - bindingKey: Color - bindingIcon: Color - bindingDescription: Color - } -} - -export type PartialContext = { - outputDelimiter?: string - timeout?: number - symbols?: { - prompt?: string - breadcrumb?: string - separator?: string - group?: string - keys?: Record - } - colors?: { - prompt?: Color - breadcrumb?: Color - separator?: Color - group?: Color - inputKeys?: Color - lastInputKey?: Color - bindingKey?: Color - bindingIcon?: Color - bindingDescription?: Color - } -} - -export const defaultContext: Context = { - outputDelimiter: '\t', - timeout: 0, - symbols: { - prompt: ' ', - breadcrumb: ' » ', - separator: '➜', - group: '+', - keys: { - space: '␣', - return: '⏎', - tab: '⇥', - up: '↑', - down: '↓', - right: '→', - left: '←', - home: '⇱', - end: '⇲', - pageup: '⇞', - pagedown: '⇟', - insert: '⎀', - delete: '⌦', - F1: '󱊫', - F2: '󱊬', - F3: '󱊭', - F4: '󱊮', - F5: '󱊯', - F6: '󱊰', - F7: '󱊱', - F8: '󱊲', - F9: '󱊳', - F10: '󱊴', - F11: '󱊵', - F12: '󱊶', - }, - }, - colors: { - prompt: 8, - breadcrumb: { - color: 8, - attrs: ['dim'], - }, - separator: { - color: 8, - attrs: ['dim'], - }, - group: 8, - inputKeys: 8, - lastInputKey: -1, - bindingKey: -1, - bindingIcon: 8, - bindingDescription: 8, - }, -} - -export function mergeContext(userDefinedContext: PartialContext): Context { - return { - ...defaultContext, - ...userDefinedContext, - symbols: { - ...defaultContext.symbols, - ...userDefinedContext.symbols, - keys: { - ...defaultContext.symbols.keys, - ...userDefinedContext.symbols?.keys, - }, - }, - colors: { - ...defaultContext.colors, - ...userDefinedContext.colors, - }, - } -} diff --git a/src/ui.ts b/src/ui.ts index 1176ea5..5a69cd0 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,7 +1,6 @@ import { bold, dim, hidden, inverse, italic, rgb24, rgb8, strikethrough, underline } from '@std/fmt/colors' import { border as defaultBorder, Table } from '@cliffy/table' -import { type Binding } from './types/Binding.ts' -import { type Color, type Context } from './types/Context.ts' +import { type Binding, type Color, type Context } from './schema.ts' function color(text: string, givenColor: Color) { const ansi256 = (typeof givenColor === 'number' || typeof givenColor === 'string') ? givenColor : givenColor.color