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
36 changes: 6 additions & 30 deletions .claude/skills/add-problem/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,38 +54,15 @@ description: vimro の問題 JSON を作成する。カテゴリ・狙う操作

## 検証(必須)

スクラッチパッドに一時スクリプトを作り、headless で実行する:

```lua
-- verify.lua(スクラッチパッドに置く。REPO と FILE を差し替える)
local root = "REPO" -- リポジトリ絶対パス
vim.opt.rtp:prepend(root)
local p = vim.json.decode(table.concat(vim.fn.readfile(root .. "/problems/FILE"), "\n"))
for i, s in ipairs(p.solutions) do
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, p.start)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(s.keys, true, false, true), "x", false)
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "x", false)
local got = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
assert(require("vimro.engine").is_cleared(got, p.goal),
("solution %d (%s) failed: %s"):format(i, s.keys, vim.inspect(got)))
end
print("OK")
```
リポジトリルートで実行する。solutions の再生とスキーマを機械確認する(CI でも同じものが走るが、壊れた PR を出さないためにローカルで先に通す):

```sh
nvim --headless -u NONE -l verify.lua
nvim --headless -u NONE -l scripts/verify_problems.lua
```

これで **全 solutions の keys が start を goal に本当に変形するか**を機械確認する。失敗したら `start` / `goal` / `keys` を見直す。

あわせて次も目視確認する:
失敗すると該当 `id` と実際に生成されたバッファ内容が出るので、`start` / `goal` / `keys` を見直す。

- `notes` の要素数が `solutions` と一致しているか(ja / en 両方)
- `id` の重複がないか(`grep -r '"id"' problems/<category>/`)
- `description` と `goal` が矛盾していないか
これに加えて **`description` と `goal` が矛盾していないか**だけは目視で確認する(機械検証できない唯一の項目)。

## PR の作成

Expand All @@ -103,12 +80,11 @@ gh pr create --base main --label problems \
## Summary
- 追加した問題と狙う操作を1〜2行で

## Verification
- headless Neovim で全 solutions を再生し goal に到達することを確認

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

`problems` ラベルはリポジトリに作成済みなので `--label problems` をそのまま使う。付与に失敗した場合のみ `gh pr edit <番号> --add-label problems` で追う。作成後は PR の URL をユーザーに伝える。

**ラベルはリリースのバージョンを決める**: main にマージされると `.github/workflows/release.yml` が自動でタグを打ってリリースする。`problems` ラベル(およびラベル無し)は patch、`enhancement` は minor、`breaking` は major。問題追加は patch のままでよいので、通常は `problems` だけ付ければよい。
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
verify:
name: Verify problems
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: stable
- run: nvim --headless -u NONE -l scripts/verify_problems.lua

load:
name: Load plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: stable
- run: nvim --headless -u NONE -l scripts/load_check.lua
96 changes: 96 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Release

on:
push:
branches: [main]

permissions:
contents: write
pull-requests: read

concurrency:
group: release
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: stable

- name: Verify before releasing
run: |
nvim --headless -u NONE -l scripts/verify_problems.lua
nvim --headless -u NONE -l scripts/load_check.lua

- name: Skip if this commit is already tagged
id: tagged
run: |
if git tag --points-at HEAD | grep -q '^v'; then
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Decide the next version
id: version
if: steps.tagged.outputs.skip == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail

labels=$(gh pr list --state merged --search "$GITHUB_SHA" \
--json labels --jq '.[0].labels[].name' 2>/dev/null || true)
echo "labels: ${labels:-<none>}"

bump=patch
if grep -qx 'breaking' <<<"$labels"; then
bump=major
elif grep -qx 'enhancement' <<<"$labels"; then
bump=minor
fi

prev=$(git tag --list 'v*' --sort=-v:refname | head -1)
prev=${prev:-v0.0.0}
IFS=. read -r major minor patch <<<"${prev#v}"

case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac

next="v${major}.${minor}.${patch}"
echo "$prev -> $next ($bump)"
{
echo "prev=$prev"
echo "next=$next"
} >> "$GITHUB_OUTPUT"

- name: Tag and release
if: steps.tagged.outputs.skip == 'false'
env:
GH_TOKEN: ${{ github.token }}
PREV: ${{ steps.version.outputs.prev }}
NEXT: ${{ steps.version.outputs.next }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$NEXT" -m "$NEXT"
git push origin "$NEXT"

gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="$NEXT" -f previous_tag_name="$PREV" \
--jq .body > notes.md
./scripts/problem_delta.sh "$PREV" >> notes.md

gh release create "$NEXT" --title "$NEXT" --notes-file notes.md
21 changes: 15 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,38 @@ Run the plugin from the repo without installing it:
nvim --cmd "set rtp+=$PWD" +Vimro
```

Verify a problem's solutions actually transform `start` into `goal` (headless, no config):
Verify every problem — schema plus a real headless replay of each solution against `goal`:

```sh
nvim --headless -u NONE -l verify.lua # see .claude/skills/add-problem/SKILL.md for the script
nvim --headless -u NONE -l scripts/verify_problems.lua
```

That headless-replay pattern is the only automated check in the repo — use it whenever problem JSON changes.
Check that every module still loads:

```sh
nvim --headless -u NONE -l scripts/load_check.lua
```

Both run in CI (`.github/workflows/ci.yml`) on every PR and push to main. Run `verify_problems.lua` locally whenever problem JSON changes — a failure prints the offending `id` and the buffer it actually produced.

## Release automation

Merging to `main` releases automatically (`.github/workflows/release.yml`): it re-runs both checks, reads the merged PR's labels to pick the bump (`breaking` → major, `enhancement` → minor, anything else including `problems` → patch), tags `vX.Y.Z` off the latest `v*` tag, and publishes a GitHub release. Notes are GitHub's generated ones plus a problem-count delta from `scripts/problem_delta.sh`. A commit that already carries a `v*` tag is skipped, so re-runs are safe. Tags without the `v` prefix (`1.0.0`, `1.1.0`) are legacy and ignored by the version lookup.

## Architecture

Five modules, each with one job. `ui.lua` is the only one that knows about the others.
Four modules, each with one job. `ui.lua` is the only one that knows about the others.

- **`engine.lua`** — no UI, no state beyond the progress file. Loads/sorts problems (difficulty, then id), decides clearing, persists progress. `root()` derives the plugin directory from `debug.getinfo`, so `problems/` is always found relative to the installed plugin, not the cwd.
- **`ui.lua`** — session state (`S`), the two-window tab layout, keymaps, rendering, startup flow. Everything user-visible lives here.
- **`config.lua`** — `M.defaults` is the single source of truth for options; `setup()` deep-extends into `M.options`. `ui.lua` reads `config.options` live, so runtime mutation (e.g. the language picker setting `config.options.lang`) takes effect immediately.
- **`i18n/`** — `t(key, ...)` for UI strings, `resolve_problem()` for per-problem text. Both fall back `lang` → `fallback_lang`; `t()` finally falls back to the key itself, so a missing string degrades rather than errors.
- **`keys.lua`** — keystroke counter via a single `vim.on_key` handler, counting only while the practice buffer is current.

### Things that will bite you

- **Clear matching is deliberately loose in one axis only**: `engine.normalize` strips trailing whitespace per line and drops trailing blank lines. Line contents and line count are otherwise strict. Problems whose answer depends on trailing whitespace cannot be expressed.
- **Keymaps in the practice buffer must stay behind `buffer_prefix`.** Binding plain `n` / `r` there would shadow the exact Vim motions being trained. The problem pane is where bare keys are safe.
- **Categories are hardcoded in `ui.lua`** (`local categories = { "plain" }`) even though `engine.load_problems` accepts any directory name. Adding a new `problems/<category>/` requires editing that list too.
- **Categories are auto-detected** by `engine.list_categories()` from the subdirectories of `problems/`, so a new category needs no code change.
- **Every problem starts at `[1, 1]`.** There is no per-problem start position: getting to the spot you edit is part of the drill, and a mid-line start reads as a leftover cursor from the previous problem. Solutions must therefore include whatever motion they need.
- **`quit()` has two exit paths**: started from an empty Neovim (`is_fresh_nvim`) it runs `qa` and exits the editor; otherwise it tears down the tab/split and wipes buffers. Both must leave `S` clean, since `M.start()` guards re-entry on `S.active`.
- Both buffers are `nofile` + `bufhidden=wipe`, and a `BufWipeout` autocmd on the practice buffer ends the session — wiping it from anywhere is a supported way to quit.
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ require("vimro").setup({

- 言語非依存フィールドを埋める: `id` / `category` / `difficulty` / `start` / `goal` / `solutions`(`keys` は `<Esc>` などの vim 表記で、常に 1 行目 1 文字目から始まる。最短解に `"optimal": true`)/ `tags`
- `i18n.ja` と `i18n.en` の両方を埋める(`title` / `description` / `hints` / `notes`)。`notes[i]` は `solutions[i]` の説明
- 各 `solutions[].keys` が本当に `start` を `goal` に変形することを Neovim で確認する
- `nvim --headless -u NONE -l scripts/verify_problems.lua` で検証する。全 `solutions[].keys` を実際に再生し、スキーマも合わせて確認する(同じスクリプトが CI でも走る)
- 1 問につき狙う操作は 1 つに絞り、`description` と `goal` を必ず一致させる

完全な例は `problems/plain/001-delete-word.json` を参照してください。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Problems live in `problems/<category>/NNN-<slug>.json`, one file per problem:

- Fill the language-independent fields: `id`, `category`, `difficulty`, `start`, `goal`, `solutions` (`keys` in Vim notation like `<Esc>`, starting from the first character of the buffer, mark the shortest with `"optimal": true`), `tags`.
- Fill both `i18n.ja` and `i18n.en` (`title`, `description`, `hints`, `notes`). `notes[i]` describes `solutions[i]`.
- Verify in Neovim that each `solutions[].keys` really transforms `start` into `goal`.
- Verify with `nvim --headless -u NONE -l scripts/verify_problems.lua`, which replays every `solutions[].keys` and checks the schema. The same script runs in CI on every pull request.
- Aim for one target operation per problem, and keep `description` consistent with `goal`.

See `problems/plain/001-delete-word.json` for a complete example.
Expand Down
31 changes: 31 additions & 0 deletions scripts/load_check.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Require every module under lua/vimro/ so syntax and load-time errors surface.
--
-- nvim --headless -u NONE -l scripts/load_check.lua

local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")
vim.opt.rtp:prepend(root)

local files = vim.fn.glob(root .. "/lua/vimro/**/*.lua", false, true)
table.sort(files)

local failed = false
for _, file in ipairs(files) do
local mod = file:sub(#root + #"/lua/" + 1):gsub("%.lua$", ""):gsub("/init$", ""):gsub("/", ".")
local ok, err = pcall(require, mod)
if not ok then
failed = true
io.stderr:write(("%s: %s\n"):format(mod, err))
end
end

-- plugin/ scripts are sourced by Neovim, not required
local ok, err = pcall(vim.cmd, "source " .. root .. "/plugin/vimro.lua")
if not ok then
failed = true
io.stderr:write("plugin/vimro.lua: " .. tostring(err) .. "\n")
end

if failed then
vim.cmd("cq")
end
io.stdout:write(("OK - %d modules loaded\n"):format(#files))
20 changes: 20 additions & 0 deletions scripts/problem_delta.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Summarize problems added between a previous ref and HEAD, for release notes.
#
# scripts/problem_delta.sh v1.2.1
#
# Prints nothing when no problems were added.
set -euo pipefail

prev="${1:-}"
[ -n "$prev" ] || exit 0

added=$(git diff --diff-filter=A --name-only "$prev" HEAD -- 'problems/*/*.json' || true)
[ -n "$added" ] || exit 0

count=$(printf '%s\n' "$added" | wc -l | tr -d ' ')
by_category=$(printf '%s\n' "$added" | cut -d/ -f2 | sort | uniq -c |
awk '{ printf "%s%s: %s", sep, $2, $1; sep = ", " }')
total=$(git ls-files 'problems/*/*.json' | wc -l | tr -d ' ')

printf '\n## Problems\n\n+%s problems (%s) — %s total\n' "$count" "$by_category" "$total"
Loading
Loading