Thanks for considering a contribution. binvim is a small project with a small surface area, and the bar for merging is "the code matches the existing style and the change is something the maintainer wants in the editor." This document covers what you need to know before opening a PR.
binvim is source-available, not open source — see LICENSE for the full text. The short version that matters for contributors:
- You may clone, build, and modify binvim for your own use on hardware you control.
- Fork away to open a PR — publicly is fine, and on GitHub it's the only option, since a PR can't be opened from a private fork. What you can't do is keep it running as a project of its own: leave the fork relationship intact, don't publish releases or registry entries from it, and delete it (or flip it private) once your PR is merged or closed. LICENSE §3 spells this out as a "Contribution Fork".
- You may not redistribute binvim, publish binaries, or stand up a copy of the repo as its own project.
- By submitting a PR you grant the maintainer a perpetual, irrevocable, sublicensable licence to use your contribution as part of binvim, including under different licence terms in the future (LICENSE §4). You also represent that the work is yours to grant.
If you can't agree to that, please don't open a PR.
For anything bigger than a one-line fix, open an issue first. binvim is opinionated about what it includes — pre-agreed scope avoids the awkward case where a working PR gets closed because the feature isn't wanted. Good things to flag up front:
- New language / LSP support.
- New keybindings or operators — the parser is a Vim-grammar state machine; new verbs need to fit it, not bolt onto it.
- New configuration surface —
~/.config/binvim/config.tomlis intentionally minimal. - New external-tool dependencies — every external binary in the README install table is one more thing that can be missing on a user's machine.
- New DAP adapters — the adapter registry in
src/dap/specs.rsis the only entry point; adapter-specific behaviour belongs there, not inmanager.rs.
Bug fixes, missing-LSP arms, and tree-sitter additions don't need a pre-discussion — just open the PR.
cargo build # debug build
cargo build --release # release build (target/release/binvim)
cargo test # full suite, ~640 unit tests
cargo test motion::tests # one module
cargo test motion::tests::word_forward_basic # one test
cargo run -- path/to/file # debug-build runCI runs cargo test, cargo clippy and cargo fmt --check on every PR. Both cargo fmt --check and clippy are gating — run cargo fmt before you push, and cargo +1.98.0 clippy --locked --all-targets -- -D warnings (CI pins clippy to 1.98.0 and fails on any warning; a newer local clippy flags different things). The formatting config is rustfmt.toml at the repo root; max_width = 100 plus single_line_let_else_max_width = 100 keeps compact let … else and single-line method chains intact.
If this is your first PR to the repo, GitHub holds the workflow run until the maintainer approves it — a PR sitting with no checks reported is waiting on that, not broken.
If you're testing changes by running binvim interactively, remember that the install/alias path is target/release/binvim — a debug build will not be picked up. Run cargo build --release after the change you want to exercise.
These are not stylistic preferences — they are how the codebase is structured, and PRs that fight them tend to get bounced.
- Mostly flat
src/layout, with three sub-module dirs.app/,lsp/, anddap/are split across multiple files (each parent file —src/app.rs,src/lsp.rs,src/dap.rs— is a slim entry that declares children and re-exports the public API). Other modules stay flat — don't introduce newsrc/foo/directories without a real reason. Insideapp/, sibling-visible methods arepub(super). - No new files unless necessary. Prefer extending an existing module. New top-level files need to justify themselves.
- Tests live inline, in
#[cfg(test)] mod testsat the bottom of the file under test. No separatetests/directory, notests/integration/.motion.rsandtext_object.rshave the densest coverage and are the model. - Comments explain why, not what. The existing comments in
lang.rs(priority resolution),lsp/manager.rs(debounce/drain cap), andapp/state.rs(BufferStash shape) are the pattern: load-bearing context that isn't obvious from the code. Don't add what-comments. Don't add multi-paragraph docstrings. - No backwards-compatibility shims, feature flags, or
// removedmarkers for code that's been deleted. Just delete it. - Don't over-abstract. Three similar lines is better than a premature abstraction. Don't design for hypothetical future requirements.
- LF line endings only. No CRLF.
For a longer tour see CLAUDE.md. The 30-second version:
app.rs+app/own the event loop, active buffer, per-buffer stashes, and all transient UI state. TheAppstruct lives inapp.rs; child files inapp/(state, view, search, registers, buffers, save, edit, visual, comment, multi_cursor, dispatch, input, lsp_glue, dap_glue, git_glue, copilot, picker_glue, quickfix, windows, health, pair) holdimpl super::Appblocks grouped by concern. Action dispatch isapp/dispatch.rs.parser.rsturnsKeyEvents intoActionvalues via the Vim-grammar state machine. Operators, motions, text-objects, counts, registers, leader, surround — all resolved here beforeapp/dispatch.rssees them.motion.rsandtext_object.rsare pure functions over(buffer, cursor). New motions or text objects belong here, with tests inline.window.rs+layout.rscarry the split system:Windowis a view (cursor, viewport, visual anchor, buffer index);Layoutis the binary split tree whosepartition()emits(WindowId, Rect)per leaf and whosefocus_neighbor()does geometrich/j/k/lnavigation, not tree-order.lang.rsowns tree-sitter. The non-obvious bit: highlight captures resolve by pattern_index priority — later patterns win. JSON ships its own embedded query because the upstream pattern order is incompatible with that scheme. If you change the priority logic, the JSON block atlang.rs:88is the canary.lsp.rs+lsp/is a from-scratch JSON-RPC client (entry + types/specs/client/io/manager/parse). Multiple servers per buffer is supported and used (e.g. tsserver + Tailwind on.tsx).didChangeis debounced with a 50ms burst window inapp/lsp_glue.rs.dap.rs+dap/is the Debug Adapter Protocol client, structurally parallel tolsp/(types/specs/client/io/manager). Adapter-specific behaviour stays indap/specs.rs;manager.rsand the wire layer are adapter-agnostic.git.rsshells out togit diff --unified=0for the per-line gutter stripe;markdown_render.rsproduces the Normal-mode conceal transforms;session.rspersists the open-buffer set per cwd to~/.cache/binvim/sessions/<hash>.json.render.rsis the only module that talks to crossterm for drawing.
The five-file change is always:
- New arm in
primary_spec_for_path(src/lsp/specs.rs). Langvariant + extension/basename entry inLang::detect()plus matchingts_language()/highlights_query()arms insrc/lang.rs(skip the tree-sitter arms only if you don't want highlighting).- Icon +
lang_namein the two exhaustiveLangmatches insrc/render.rs. - Formatter arm in
format_buffer(src/format.rs) plustree-sitter-<lang>crate inCargo.toml. - New rows in the README install table for the LSP and the formatter.
There is no plugin system. Every server is hard-wired in lsp/specs.rs. That is a deliberate choice; please don't propose a plugin loader as part of an LSP PR.
Add the crate to Cargo.toml, then a Lang variant + ts_language() arm + highlights_query() arm. If the upstream highlights query is wrong under "later pattern wins" priority (see JSON), embed a corrected query inline rather than patching the priority logic.
Three touchpoints:
src/dap/specs.rs— append aDapAdapterSpectoBUILTIN_ADAPTERSwithkey,adapter_id,cmd_candidates,args,root_markers, aprelaunchfn (returnNoneif the adapter builds implicitly, e.g. delve), and abuild_launch_argsfn that produces thelaunchrequest JSON. Add per-adapter target discovery here (find_<lang>_*helper) if the picker needs to enumerate something beyond the file the user is on.src/app/dap_glue.rs— add adap_resolve_<lang>method that wraps the 0/1/many discovery → auto-pick or open theDebugTargetpicker → calldap_start_target. Register the new key indap_start_session's match arm.src/dap.rs— re-export any new public helpers / types.
Adapter-specific behaviour stays in dap/specs.rs + app/dap_glue.rs; manager.rs and the wire layer in types/io/client are adapter-agnostic. Like the LSP layer, there is no plugin system — every adapter is hard-wired. The four shipped adapters (netcoredbg, delve, debugpy, lldb-dap) are useful blueprints — pick the one whose discovery / prelaunch shape is closest to your target.
Before opening a PR:
cargo testis green.cargo build --releasesucceeds.- For LSP / language changes: open a representative file, run
:health, and confirm the server appears under LSP servers with the expectedkey,language_id, and detectedroot. Trigger completion and hover on a known symbol. - For UI / rendering / keybinding changes: actually run the release binary and use the feature. Type-checks and tests verify code correctness, not feature correctness.
- One logical change per PR. If you're tempted to write "and also fixed X" in the description, X is a separate PR.
- Branch from
main. Rebase on top ofmainbefore opening; no merge commits. - PR description should explain the why — what the user-visible behaviour was before, what it is after, and what motivated the change. The maintainer can read the diff for the what.
- No Claude / AI / "Co-Authored-By" attribution in commit messages, branch names, or PR descriptions. The
attributionCI check enforces this over every commit, the PR title and description, the branch name and the tracked files. Rungit config core.hooksPath .githooksonce per clone and a commit-msg hook refuses the commit instead, before it can reach a push. - Reference the issue number if you opened one.
Open a GitHub issue with:
- binvim version (
binvim --versionor the commit SHA you built from). - OS and terminal emulator.
- A minimal reproduction — file contents (or a path to a public repo), exact keystrokes, what you expected, what happened.
:healthoutput is often the fastest way to tell whether an LSP-shaped bug is a binvim issue or a missing server.
For LSP-specific bugs, the server logs go to stderr; running binvim 2> /tmp/binvim.log and attaching the relevant section is the most useful thing you can include.
For licensing questions outside the scope of LICENSE — redistribution, commercial use, hosted-service provision — contact the maintainer on Twitter/X at @bgunnarssonis. For everything else, the issue tracker is the right place.