org-lsp

Developing org-lsp

How org-lsp is built, checked and published: the rules the work follows, the checks each change goes through, how a version is released, and the decisions taken on the way. What Emacs Org does that org-lsp does not yet is in parity.org; what an editor integration has to do, in protocol.org.

Goal and rules#

Org mode in any editor with an LSP client (Neovim, VS Code, Vim, Helix, others), as close to Emacs Org mode as it can get.

  • Server in Rust, parser written from scratch. No tree-sitter.
  • LSP only. No second server and no protocol of its own. Where LSP has no fitting request, the feature waits or uses what LSP has (commands with arguments, server-owned documents, semantic tokens).
  • As much as possible in the server. Editors get glue: a loop over tables the server announces (commands, keys, highlight links), not logic. A new feature should need no new editor code.
  • Emacs is the reference. Org logic is ported from the Emacs sources (Org 9.8.10) and compared with Emacs by committed reference cases and by differential fuzzing. A deliberate difference is written down. The options are part of it: each of Org's options is in scripts/org-options.json, with what org-lsp does with it (a setting, missing, read by Emacs, and so on), and parity.org lists those missing. What Emacs says of those it reads (their docstrings, defaults and types, for emacs-options.org and the schema of emacs_options) is in scripts/emacs-options.json, which scripts/emacs_options_reference.py records again when an option changes status.
  • One repository, Neovim files at the root (plugin/, lua/), other editors under editors/.
  • Small changes. Each is one pull request, with its tests, its docs and the parity table, merged by squashing once the checks pass.

How the work is checked#

  • All at once: scripts/check.sh (the fast checks) and scripts/test-all.sh (those and each editor end to end, side by side, half as many at a time as there are CPUs, ORG_LSP_TEST_JOBS to say otherwise, each into a log of its own; ORG_LSP_SERIAL=1 one after another); scripts/regen.sh writes the generated files again. CLAUDE.md lists the environment they read.
  • By hand, in real editors, before a change is merged: each editor it touches, installed as users install it (Neovim through lazy.nvim and through pack/*/start, Vim through pack/*/start with each LSP client it affects, the others from what install.sh installs), the feature used as a user uses it and what shows looked at. The harnesses set the editors up their own way, which can hide what a user hits; the pull request says which editors and installs were tried.
  • Rust: cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all. CI's toolchain is newer than what was used locally and has more clippy lints; CI prints them as annotations.
  • Against Emacs (needs Emacs, faketime, and ORG_LISP_DIR pointing at the lisp directory of an Org 9.8.10 checkout):
    AreaCommitted referenceRegenerateFuzz
    Parsertests/fixtures/*.elementsscripts/update-fixtures.shscripts/fuzz-against-emacs.py
    Datestests/vectors/*.tsvscripts/gen-vectors.el
    Agenda; with --sorting, --todo-options, --skip-options, --display-options its optionstests/agenda/*/expected-N.txtscripts/update-agenda-fixtures.pyscripts/fuzz-agenda.py
    Agenda groups (org-super-agenda): with ORG_LSP_EMACS_PACKAGES; fuzzing with --supertests/agenda/super-*/scripts/update-agenda-fixtures.pyscripts/fuzz-agenda.py
    org-roam's nodes, captures, edits, buffer: with ORG_LSP_EMACS_PACKAGEStests/roam/*.jsonscripts/update-roam*-fixtures.pyscripts/fuzz-roam*.py
    org-journal's entries, carryover, motion, calendar keys: with ORG_LSP_EMACS_PACKAGEStests/journal/cases.jsonscripts/update-journal-fixtures.pyscripts/fuzz-journal.py
    TODO changestests/todo/cases.jsonscripts/update-todo-fixtures.pyscripts/fuzz-todo.py
    Priorities, timestampstests/shift/cases.jsonscripts/update-shift-fixtures.pyscripts/fuzz-shift.py
    Where Shift and the arrows are Org's and where they select (org-support-shift-select)tests/shift-select/cases.jsonscripts/shift_select_reference.py
    Agenda commandstests/agenda-actions/cases.jsonscripts/update-agenda-action-fixtures.py
    Folding subtreestests/folding/cases.jsonscripts/update-fold-fixtures.pyscripts/fuzz-fold.py
    Date prompttests/read-date/cases.jsonscripts/update-read-date-fixtures.pyscripts/fuzz-read-date.py
    Commands that ask for a datetests/planning/cases.jsonscripts/update-planning-fixtures.pyscripts/fuzz-planning.py
    Calendar of the date prompttests/calendar/emacs*.txtscripts/update-calendar-fixtures.sh
    Indentationtests/indent/cases.jsonscripts/update-indent-fixtures.pyscripts/fuzz-indent.py
    The looks of headline levels (org-get-level-face, org-n-level-faces, org-cycle-level-faces), org-fontify-whole-heading-linetests/level-faces/cases.jsonscripts/level_faces_reference.py
    The lines org-toggle-custom-properties-visibility hides (org-custom-properties)tests/properties/custom.jsonscripts/custom_properties_reference.pyscripts/fuzz-custom-props.py
    Formatting (tables, tags)tests/format/cases.jsonscripts/update-format-fixtures.pyscripts/fuzz-format.py
    RET (org-return)tests/return/cases.jsonscripts/update-return-fixtures.pyscripts/fuzz-return.py
    M-RET, M-S-RET, C-RET, C-S-RETtests/meta-return/cases.jsonscripts/update-meta-return-fixtures.pyscripts/fuzz-meta-return.py
    Promote, demote, move subtrees (M-<left>, M-S-<right>, M-<up>, …)tests/structure/cases.jsonscripts/update-structure-fixtures.pyscripts/fuzz-structure.py
    Cut, copy, paste and yank subtrees (org-cut-subtree, org-copy-subtree, org-paste-subtree, org-yank, with the kill ring and the folds after them)tests/subtree/cases.jsonscripts/update-subtree-fixtures.pyscripts/fuzz-subtree.py
    The line keys (org-beginning-of-line, org-end-of-line, org-kill-line, on folded headlines too)tests/line/cases.jsonscripts/line_reference.py
    Sorting entries (org-sort-entries, every sorting type, in the C locale at a fixed time)tests/sort/cases.jsonscripts/update-sort-fixtures.pyscripts/fuzz-sort.py
    Moving between headings and links (C-c C-n, C-c C-p, C-c C-f, C-c C-b, C-c C-u, C-c C-x C-n, C-c C-x C-p)tests/motion/cases.jsonscripts/motion_reference.pyscripts/fuzz-motion.py
    Sparse trees (org-occur, org-show-todo-tree, org-match-sparse-tree, org-check-deadlines and the date checks): the matchestests/sparse/cases.jsonscripts/update-sparse-fixtures.py
    List items: the meta and shifted keys, C-c C-c and C-c ^ on an itemtests/lists/cases.jsonscripts/update-lists-fixtures.pyscripts/fuzz-lists.py
    Setting tags (org-set-tags-command: fast tag selection by key, the completion prompt's answer)tests/tags/cases.jsonscripts/update-tags-fixtures.pyscripts/fuzz-tags.py
    Archivingtests/archive/cases.jsonscripts/update-archive-fixtures.pyscripts/fuzz-archive.py
    org-linttests/lint/cases.jsonscripts/lint_reference.py (and scripts/update-lint-tables.py for Org's tables)scripts/fuzz-lint.py
    Storing and inserting links (org-store-link, org-insert-link and its relatives, org-id-get-create)tests/link/cases.jsonscripts/link_reference.pyscripts/fuzz-link.py
    Org-protocol URLs (org-protocol.el: org-protocol-check-filename-for-protocol, the store-link, capture and open-source handlers)tests/protocol/cases.jsonscripts/update-protocol-fixtures.pyscripts/fuzz-protocol.py
    Inline tasks (org-inlinetask: C-c C-x t, M-<left> and M-<right> on one, the motion and the folds around them)tests/inlinetask/cases.jsonscripts/update-inlinetask-fixtures.py
    Resolving open clocks (org-resolve-clocks, and before org-clock-in): what is asked, and the filetests/clock/resolve.jsonscripts/clock_resolve_reference.py
    Downloading remote resources (org-resource-download-policy, org-safe-remote-resources, org--confirm-resource-safe): what is asked, the answertests/link/remote-resources.jsonscripts/remote_resource_reference.py
    BibTeX entries as headlines (ol-bibtex.el: org-bibtex, org-bibtex-create, org-bibtex-check, org-bibtex-yank, org-bibtex-import-from-file)tests/bibtex/cases.jsonscripts/update-bibtex-fixtures.pyscripts/fuzz-bibtex.py
  • The shared end-to-end scenarios (below): cargo test runs them against the server and checks that they reach every key of the agenda, the date prompt and capture; each editor's adapter presses its keys for the same steps.
  • Neovim: scripts/test-nvim.sh (set NVIM to the binary), on 0.12.0 and stable. Tests run headless against the real server; tests/nvim/scenarios.lua is its adapter of the scenarios, which runs them typed: each in Neovim's own terminal UI in tmux, nothing replaced, 'updatetime' 100. That Neovim plays the scenario on timers and writes down the keys it wants typed; the runner (--typed) types them with tmux send-keys, a key at a time, and each answer as what the screen asks for takes it: a key at a key prompt, text in the command line, the number of a choice in the list vim.ui.select shows. It presses Enter at a hit-enter prompt, as a user does, and the scenario fails when one comes before what is asked could be answered. Neovim's own typed checks, of what the scenarios do not see, are in tests/nvim/typed/NAME.json, steps of the same kind with keys typed as they are and screen, text the screen must show. ORG_LSP_NVIM_TESTS=typed scripts/test-nvim.sh [NAME…] runs only the typed ones; ORG_LSP_NVIM_TESTS=headless runs the scenarios headless instead, their keys fed and the prompts answered: the same steps, quicker to look into when a typed one fails. On a nightly build of 0.13, tests/nvim/highlight.lua may stop on Neovim's own assertion (grid_line_flush: Assertion `grid_line_clear_to <= grid_line_maxcol') when it redraws the date prompt's calendar in its headless 140 by 50 screen; the same calendar draws in a terminal of that size, and on 0.12.
  • VS Code: in editors/vscode, npm run generate -- --check, npm run check, npm run build, xvfb-run -a npm test (VSCodium works locally through VSCODE_EXECUTABLE, set to its codium binary, not the launcher in bin/). The tests of the extension run first, then editors/vscode/test/scenarios.test.cjs, its adapter of the scenarios, with each preset's keys (npm test -- scenarios vim runs one). It types everything with xdotool into VS Code's window, as the user types it: each step's keys, its text, and the answers to what a command asks, in VS Code's own quick picks and input boxes and in the date prompt (i, the date, Enter). Nothing answers for the user: the test watches what VS Code shows, checks that what is typed is taken as meant (the choice Enter would pick, the text the box has), then presses Enter. A command a preset has no key for (tests/e2e/exceptions.json) gets a key of the user's, as a count does.
  • Vim: CLIENTS=dir editors/vim/test/run.sh, with the four clients checked out in dir (lsp, vim-lsp, coc.nvim on its release branch, ale); Vim runs in tmux. editors/vim/test/scenarios.vim is its adapter of the scenarios. The tests answer prompts themselves, and the scenarios run typed, everything through tmux as the user types it (see End-to-end scenarios); editors/vim/test/typed.vim types the agenda dispatcher's key, with 'updatetime' passing while it waits for one, and the key of a prompt of two lines.
  • Sublime Text: node editors/sublime/generate.mjs --check, python3 editors/sublime/test/test_plugin.py: the plugin against stand-ins for Sublime Text and the LSP package, as Sublime itself cannot run in the workspace. The stand-ins keep Sublime's order of things: what set_timeout puts off and the server's answers come once the code that asked has returned, in the order of their time, and a panel waits for the user, who answers it a few seconds later (after a message's popup has gone); a panel shown over another cancels that one, as Sublime's do. Some keys are pressed through the package's keymap, its contexts asked of the plugin as Sublime asks them.
  • Zed: node editors/zed/generate.mjs --check, cargo build --target wasm32-wasip2 in editors/zed; the queries are compiled against the grammar with the tree-sitter CLI; with ZED set, the scenarios in Zed itself (below).
  • CI, started by hand, runs seven jobs: server, Neovim 0.12.0, Neovim stable, VS Code, Vim (with each of its four LSP clients), Sublime Text (generated files, the plugin against stand-ins), Zed (the build, generated rules, queries).

End-to-end scenarios#

What a user does in any editor, written once: tests/e2e/scenarios/NAME.json, each a few files, the options the server starts with and the steps taken. The server's runner (server/org-lsp/tests/e2e_scenarios.rs) takes them as an editor with org-lsp's plugin would, over LSP, and is the reference: what it sees is in tests/e2e/expected/NAME.txt, written again with ORG_LSP_UPDATE_E2E=1 (ORG_LSP_E2E=NAME runs only the scenarios whose name has NAME in it). Each editor has an adapter that presses that editor's keys for the same steps and must see the same; Neovim's is tests/nvim/scenarios.lua (scripts/test-nvim.sh runs it typed), Vim's editors/vim/test/scenarios.vim, which editors/vim/test/run.sh runs with each LSP client after the plugin's own tests, each scenario in a Vim of its own, four at a time:

CLIENTS=~/vim-clients editors/vim/test/run.sh                  # the tests, then the scenarios typed
ORG_LSP_VIM_TESTS=typed ORG_LSP_E2E=capture \
  CLIENTS=~/vim-clients editors/vim/test/run.sh coc            # one scenario, one client
ORG_LSP_VIM_TESTS=scenarios ORG_LSP_E2E=capture \
  CLIENTS=~/vim-clients editors/vim/test/run.sh coc            # the same, keys fed, not typed

ORG_LSP_VIM_TESTS is cases, highlight, scenarios (fed, which the typed ones cover: quicker to look into) or typed for only those, ORG_LSP_E2E_JOBS how many Vims run at once. Each scenario prints one line, with the step that failed and what it saw; a step a client cannot take as the others do is skipped, with the reason, from s:skips in scenarios.vim (coc.nvim shows the documents the server asks for itself).

The first time, the plugin's prompts are replaced by the steps' answers and the keys are fed (feedkeys()). The second time (typed, with ORG_LSP_TEST_TYPED set) nothing stands in for anything: Vim runs the steps from a timer, between turns of its main loop, with 'updatetime' at 100 ms, so CursorHold and the client's autocommands run as for a user. Where a step needs keys, Vim writes them to keys in its work directory and editors/vim/test/typist.py types them into the tmux session: the command's key, then each answer once its prompt waits (a key at a menu, text at input() after CTRL-U, the number of a choice in a list, i then the date in the date prompt's calendar), files opened with :edit, text typed in insert mode. A step is done when the server has answered and nothing has changed for a while; Vim's hit-enter prompt is a failure, except after coc.nvim's own messages about the files it edits, where Enter is typed. ORG_LSP_TEST_STOP=N leaves the typed Vim as it is after step N, to go on by hand.

A scenario has:

files
file names and their text, a string or a list of lines; or files_from, a directory under tests/ whose .org and .org_archive files it takes (the recorded agenda cases), and files on top.
clock
the server's clock, { "today": "2026-05-18", "now": "10:30" } unless it says otherwise.
options
the server's options, over those every scenario has: the clock, org_agenda_files, org_directory and org_default_notes_file in the scenario's directory. $DIR in a string is that directory.
emacs
true for a scenario that needs Emacs (column view): it is skipped without it, unless ORG_LSP_REQUIRE_EMACS is set.
steps
what is done, in turn. A step may open a file ("open": "work.org"), go back to a buffer that is open ("focus": "agenda"), close the agenda ("close": true), put the cursor on a line ("line": 3, from 1) or where some text is ("at": "Call the client", "column" further on), type text where the cursor is ("type": "Call mom", as in insert mode), and run a command by its name in the manifest ("command": "org.agenda.later"), with a count, "below": true for the keys of modal editors that add below the cursor's line (<Leader><CR>), and the answers to what it asks, in turn: a key, text, { "choice": "Projects" } for a choice by its label, or a date, typed in the date prompt (org.calendar.type). "wait": "columns" waits for column view, which Emacs works out. The adapter presses the command's key in the editor (the first; it checks that the others are set too), where the cursor is: the agenda, the date prompt, a capture buffer, an Org file.
expect
after the step, what to look at: agenda (the agenda's text), file:NAME (a file, as the editor has it), buffer (the current buffer's name and text), cursor (its name, the cursor's line and that line's text), messages (what was said since they were last looked at), columns (column view). "emacs": "agenda/NAME/expected-N.txt" on a step also checks the agenda against that view, recorded from Emacs.

scenarios_cover_the_agenda_calendar_and_capture fails when a command with a key in the agenda, the date prompt or a capture buffer is in no scenario, unless tests/e2e/exceptions.json names it under commands with the reason. An adapter fails on a command that has no key in its editor, unless editors.EDITOR there says why; it then runs the command as from the editor's command list. snapshots.EDITOR names, as NAME STEP KIND, what an editor shows otherwise on purpose, and why; steps.EDITOR, as NAME STEP, the steps an editor cannot take, and scenarios.EDITOR the scenarios it cannot run at all, each with the reason.

VS Code's adapter is editors/vscode/test/scenarios.test.cjs, run by npm test after the extension's tests (npm test -- scenarios for the scenarios only, npm test -- scenarios vim for one preset; ORG_LSP_E2E=NAME as above). Each scenario runs in a VS Code of its own, with two presets of org-lsp.keys: emacs_full, all of Emacs's keys (editors.vscode in the exceptions), and vim (editors.vscode-vim, which leaves the keys of Vim's normal mode in Org files to VSCodeVim). The adapter finds the key package.json binds to the step's command where the cursor is, by its when clause, and presses it in VS Code's window with xdotool, so that VS Code's own key dispatch runs; it checks that the key ran that command. VS Code gives a key no count: for a step with a count, test/run.mjs binds a key of the user's with "args": { "count": N }, as a user does. A step below puts the cursor at the end of its line first, as C-e M-RET in Emacs. What is asked is answered by stubs of VS Code's quick pick and input box. It needs xdotool and a display of its own, xvfb-run -a npm test: the keys go to the window that has the focus.

Helix's adapter is editors/helix/test/run.py: each scenario in a Helix of its own in a tmux session, with editors/helix/languages.toml pointing at the debug build and the scenario's options as its config. It does what a Helix user does, every key typed in Helix's terminal through tmux: picks the command's code action in the menu (Space a, then C-n and RET), in Org files, the agenda and capture buffers, follows a link with gd, saves with :write-all (the agenda's save, which Helix does not do for a server), picks the answer to a choice from the code actions, where it comes first, answers what the server asks for as text with a rename (Space r, the answer typed), goes to a line with :goto or a search, opens a file with :open and goes back to the agenda from the buffer picker (Space b). A code action takes no count: a step with one picks the action that many times, or once where Org gives the count no meaning (COMMAND count under editors.helix). A step that adds below the cursor's line goes past its end first (gl l), as C-e M-RET does, and after moving a subtree, whose code action leaves the cursor where it was in the text, the adapter goes after the subtree with a search. What Helix cannot do (follow mode, the entry in another window, a count Org reads as more than a repeat) is under steps.helix, whose steps still put the cursor where they say for the steps after them. It reads the buffers' text from what Helix tells the server (didOpen and didChange in its log), the cursor from the status line and the messages from the log too. The date prompt and column view are not there (scenarios.helix). HX is the hx to run (HELIX_RUNTIME its runtime directory, by default the one beside it; ORG_LSP_HX_KEEP keeps each scenario's directory and Helix's log); scripts/test-all.sh runs it when HX is set:

HX=~/helix/hx editors/helix/test/run.py
HX=~/helix/hx ORG_LSP_E2E=org-editing editors/helix/test/run.py

Zed's adapter is editors/zed/test/run.py, which scripts/test-all.sh runs when ZED names Zed's command line (bin/zed):

ZED=~/zed.app/bin/zed ZED_ORG_GRAMMAR=~/org.wasm editors/zed/test/run.py
ZED=… ZED_ORG_GRAMMAR=… ORG_LSP_E2E=capture editors/zed/test/run.py   # one scenario

Each scenario runs in a Zed of its own, on an X display of its own (Xvfb), with a home directory of its own: the extension installed as scripts/install.sh zed installs it, built with cargo build --release --target wasm32-wasip2 in editors/zed, the Org grammar Zed would build (ZED_ORG_GRAMMAR, an org.wasm; or ZED_TREE_SITTER_ORG, a checkout of tree-sitter-org, and WASI_SDK to build it), and the tasks and the keymap of keys through tasks. Every step is typed in Zed's window with xdotool, as a user types it. The adapter presses the key keymap.json binds to the step's command (a task, which reaches the server through its command port, or Zed's own workspace::SaveAll for the agenda's save, after which the files are checked on disk); a command with no key in Zed (editors.zed in the exceptions) runs from the code actions (ctrl-., down to the action, RET). It clicks the buttons of a question with the mouse (Zed's notifications take no keys), answers typed text with a rename at the cursor (F2, the answer typed), goes to a line with ctrl-g and opens a file, or goes back to the agenda, with the file finder (ctrl-p, the file's name typed, RET). A key that takes a count is pressed that many times, as Zed gives a key none. One key is the adapter's own, bound to Zed's workspace::ClearAllNotifications: the messages it clears would cover a question's buttons. Zed opens a file a server's edit changes when no tab shows it, and takes the focus to what it is asked to show without it (follow mode): the adapter goes back to where it was, as a user does. What Zed has open, the text of its buffers and where its cursor is come from what it tells the server, which editors/zed/test/proxy.py writes down; the current buffer is the window's title. snapshots.zed in the exceptions names what Zed shows otherwise as NAME STEP KIND, and the steps it cannot take as NAME STEP; scenarios.zed the scenarios it cannot take at all (the date prompt, column view). After the scenarios come checks of Zed's own, written as they are, with what they expect (CHECKS in run.py): zed-capture-here captures with ctrl-c c in an Org file, whose %a links to the cursor's heading, with the agenda open too. Each wait has a bound: a request left with no answer fails the step, and a scenario fails when it takes more than ORG_LSP_ZED_BUDGET seconds (600), with the step and what it waited for. Stopped (SIGTERM, SIGINT), the adapter closes its Zeds and their displays. A failure leaves a screenshot in target/zed-e2e; ORG_LSP_ZED_KEEP=1 keeps each scenario's directory, and ORG_LSP_ZED_PAUSE=1 leaves Zed open at a failure, to look at. Each Zed takes the first free X display from ORG_LSP_ZED_DISPLAY (90) up.

Highlighting#

tests/e2e/highlight is a sample of every construct the server colours: sample.org, an agenda file (agenda.org: scheduled, deadlines near and far, an event, a range, a done entry, a blocked one, a running clock, a habit), a journal and daily notes for the date prompt, and the options they need (options.json, $DIR the directory it is copied to). Each check copies it and starts the server with those options.

  • server/org-lsp/tests/highlight.rs (cargo test): the tokens of the file, the week's agenda, the TODO list and the date prompt at the places its table EXPECTED names, which has every token type and modifier of the legend; and a token for each look of the manifest's highlights. ORG_LSP_HIGHLIGHT_DUMP=1 prints every token.
  • Neovim, tests/nvim/highlight.lua (scripts/test-nvim.sh): for each look, the token's group drawn at a token it applies to, linked to the group the manifest names, defined in the default colorscheme, and its colors what shows where no more specific look applies; what Org hides concealed.
  • Vim, editors/vim/test/highlight.vim (editors/vim/test/run.sh, each client; ORG_LSP_VIM_TESTS=highlight for it alone): a text property of each look's type, its group linked to one the colorscheme defines. Vim conceals nothing.
  • VS Code, editors/vscode/test/highlight.test.cjs (npm test -- highlight): a token for each look of semanticTokenScopes, a color for each from the color theme, probed as VS Code probes the scopes; what Org hides concealed.
  • scripts/highlight-screenshots.py [nvim|helix|vscode|zed]: pictures of the sample in each editor, in target/highlight-screenshots, to look at (Zed and Helix have no other check). It reads NVIM, HX, VSCODE_EXECUTABLE, ZED and ZED_ORG_GRAMMAR; VS Code's are taken by npm test -- screenshots. Helix has no semantic tokens: it shows its own tree-sitter highlighting.

Setting up the tools#

The checks against Emacs and the editors' tests need the tools installed. What works on a Debian or Ubuntu machine: apt-get install emacs-nox faketime; Org 9.8.10 from its GitHub mirror (git clone --depth 1 --branch release_9.8.10 https://github.com/bzg/org-mode, then make compile), with ORG_LISP_DIR set to its lisp directory; Neovim and VSCodium from their GitHub releases. Compiled, Org loads three times faster, and works as it does where users install it: a defsubst such as org-current-line is then part of the functions that call it, and the bridge cannot stand in for it.

Publishing#

org-lsp has one version, written in every place that needs it: the server's workspace (Cargo.toml, Cargo.lock), the VS Code extension (package.json, package-lock.json), the Zed extension (extension.toml, its Cargo.toml and Cargo.lock) and the Sublime Text package (VERSION in plugin.py). scripts/version.sh 0.2.0 sets it in all of them; scripts/version.sh alone prints it, and fails when they disagree, which scripts/check.sh checks.

A release is a version tag. The workflow New version (.github/workflows/version.yml), run by hand on main (Actions › New version › Run workflow), makes one: it sets the version, the last one with its last number one more (0.1.13 makes 0.1.14) or the one given (version, 0.2.0), in a pull request Version 0.1.14, merges it, tags the merged commit v0.1.14 and runs the Release workflow for the tag. It fails, changing nothing, when the tag is there already. When it cannot merge the pull request, merge it by hand and run the workflow again: a version on main with no tag yet is released as it is.

The workflow needs, in the repository's settings, Actions › General › Workflow permissions › Allow GitHub Actions to create and approve pull requests, and main's protection has to let a pull request be merged with no approving review.

By hand, the same is: set the version, merge that change as any other, then tag the commit on main and push the tag:

scripts/version.sh 0.2.0
# … pull request, merged …
git fetch origin
git tag v0.2.0 origin/main
git push origin v0.2.0

The tag can also be made on GitHub's page, Releases › Draft a new release: the workflow then puts the files on that release, its title and notes as written there. A release that failed is made again by running the workflow by hand on main with its tag (Actions › Release › Run workflow, tag v0.2.0): it builds that tag again and publishes it, as this workflow does, where running it again for the tag would be the workflow as it was at the tag.

The Release workflow (.github/workflows/release.yml) fails when the tag is not v and the version, builds everything, makes the GitHub release, then publishes it: each of these jobs is skipped while its secret is not set, and the nightly build (each push to main, or the workflow run by hand on main) publishes none of them.

vscode-marketplace (the environment marketplace's AZURE_CLIENT_ID, or the secret VSCE_PAT) :: the VS Code packages, one a platform, on the VS Code Marketplace;
open-vsx (OVSX_PAT)
the same packages on Open VSX.

The VS Code packages have the server inside. The Zed extension and the Sublime Text package download the server of their own release (v0.2.0 for version 0.2.0); those of the nightly build, the nightly server.

The secrets go in the repository's settings, under Secrets and variables › Actions (https://github.com/kristijanhusak/org-lsp/settings/secrets/actions). What has to be done once, by hand, before each job can publish:

VS Code Marketplace#

The job publishes as an app of Microsoft Entra ID, which GitHub Actions signs in as with no secret of its own (a federated credential), and which is a member of the publisher. What has to be done once:

  • The publisher kristijanhusak (Kristijan Husak) exists: https://marketplace.visualstudio.com/manage/publishers/kristijanhusak.
  • An Azure account (https://azure.microsoft.com/free), whose sign-up makes its Microsoft Entra ID tenant.
  • The app: in the Azure portal, Microsoft Entra ID › App registrations › New registration, Single tenant, no redirect URI. Its Overview has its Application (client) ID and Directory (tenant) ID.
  • Its federated credentials: Certificates & secrets › Federated credentials › Add credential, GitHub Actions deploying Azure resources, the organization kristijanhusak, the repository org-lsp, the entity Environment marketplace. GitHub's tokens name the repository with or without the IDs of its owner and itself, so there are two, one of each subject: repo:kristijanhusak@1782860/org-lsp@1404580301:environment:marketplace and repo:kristijanhusak/org-lsp:environment:marketplace.
  • The environment marketplace in the repository (Settings › Environments), with the secrets AZURE_CLIENT_ID and AZURE_TENANT_ID.
  • The app a member of the publisher: run Actions › Marketplace identity › Run workflow (.github/workflows/marketplace-identity.yml), which signs in as the app and says its ID; add that ID at the publisher's Members as a Contributor. Run it again: its last step says whether the app may publish as kristijanhusak.

The job publishes with the secret VSCE_PAT instead where the environment has no AZURE_CLIENT_ID: a personal access token of an Azure DevOps organization of the publisher (User settings › Personal access tokens, All accessible organizations, Marketplace › Manage), which Azure DevOps takes until December 1, 2026 (Publishing Extensions). The packages of a GitHub release can also be uploaded by hand at https://marketplace.visualstudio.com/manage.

Open VSX#

Decisions#

  1. Typed input over LSP only. Decided: the editor's input box, and a calendar for dates; in an editor without org-lsp's code, a rename at the cursor (see any-editor.org).
  2. Licence. Decided: the GNU GPL, version 3 or later (LICENSE), as Org mode's: org-core ports Org's own code, which makes it a work based on Org. (MIT before.)
  3. Saving after edits from the agenda. Decided: saved by default (auto_save), unlike Emacs: the files a command changes other than the one it was run in (from the agenda, a capture or refile target) are saved once the editor has applied the change (the org-lsp/save notification). Off, they stay unsaved, as in Emacs, and org.agenda.save (org-save-all-org-buffers, s in the agenda) saves them.
  4. Vim. Decided: one Vimscript plugin with the logic the Neovim plugin has (manifest, keys, the agenda and date prompt buffers, closing them), on top of whichever LSP client is installed. Generic LSP (highlighting where the client supports semantic tokens, outline, folding, code actions) needs nothing. Running commands with the cursor position and answering the server's requests differ per client (vim-lsp, yegappan/lsp, coc.nvim, ALE), so the plugin has a small adapter per client: send a request, receive one. Keep as much as possible in the shared code, and write adapters for the most used clients: yegappan/lsp (Vim 9's own), vim-lsp, coc.nvim and ALE. Clients without server-owned documents get the agenda as a file (the server already writes one) and the plain input box instead of the calendar.
  5. A small priority cookie before COMMENT. Decided: * [#a] COMMENT x is not a commented headline, in every view. Emacs works out :commentedp once, when it is first asked, and keeps it: the agenda and the TODO list ask with case-fold-search nil, where [#a] is no cookie, a tags match with it on, where it is one and the headline is commented. What a view shows then depends on which view ran first. org-lsp reads the cookie with case, as the agenda, org-get-heading and org-element-context do, and scripts/agenda_reference.py asks Emacs that way before any view, so the recorded cases agree.
  6. org-protocol. Decided: every server listens for org-protocol URLs on a local socket of its own, beside LSP, as the command port does for Zed's keys: a URL comes from outside any editor, and LSP has no way in for it. org-lsp protocol URL finds the server the user worked in last through the state directory, and a server the editor of org_protocol_editor starts takes what was left for it (protocol.org). A Unix socket in a directory only the user may enter (a loopback port with a token on Windows); nothing from a URL is run.