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 ofemacs_options) is inscripts/emacs-options.json, whichscripts/emacs_options_reference.pyrecords again when an option changes status. - One repository, Neovim files at the root (
plugin/,lua/), other editors undereditors/. - 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) andscripts/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_JOBSto say otherwise, each into a log of its own;ORG_LSP_SERIAL=1one after another);scripts/regen.shwrites the generated files again.CLAUDE.mdlists 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 throughpack/*/startwith each LSP client it affects, the others from whatinstall.shinstalls), 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, andORG_LISP_DIRpointing at thelispdirectory of an Org 9.8.10 checkout):Area Committed reference Regenerate Fuzz Parser tests/fixtures/*.elementsscripts/update-fixtures.shscripts/fuzz-against-emacs.pyDates tests/vectors/*.tsvscripts/gen-vectors.elAgenda; with --sorting,--todo-options,--skip-options,--display-optionsits optionstests/agenda/*/expected-N.txtscripts/update-agenda-fixtures.pyscripts/fuzz-agenda.pyAgenda groups (org-super-agenda): with ORG_LSP_EMACS_PACKAGES; fuzzing with--supertests/agenda/super-*/scripts/update-agenda-fixtures.pyscripts/fuzz-agenda.pyorg-roam's nodes, captures, edits, buffer: with ORG_LSP_EMACS_PACKAGEStests/roam/*.jsonscripts/update-roam*-fixtures.pyscripts/fuzz-roam*.pyorg-journal's entries, carryover, motion, calendar keys: with ORG_LSP_EMACS_PACKAGEStests/journal/cases.jsonscripts/update-journal-fixtures.pyscripts/fuzz-journal.pyTODO changes tests/todo/cases.jsonscripts/update-todo-fixtures.pyscripts/fuzz-todo.pyPriorities, timestamps tests/shift/cases.jsonscripts/update-shift-fixtures.pyscripts/fuzz-shift.pyWhere Shift and the arrows are Org's and where they select ( org-support-shift-select)tests/shift-select/cases.jsonscripts/shift_select_reference.pyAgenda commands tests/agenda-actions/cases.jsonscripts/update-agenda-action-fixtures.pyFolding subtrees tests/folding/cases.jsonscripts/update-fold-fixtures.pyscripts/fuzz-fold.pyDate prompt tests/read-date/cases.jsonscripts/update-read-date-fixtures.pyscripts/fuzz-read-date.pyCommands that ask for a date tests/planning/cases.jsonscripts/update-planning-fixtures.pyscripts/fuzz-planning.pyCalendar of the date prompt tests/calendar/emacs*.txtscripts/update-calendar-fixtures.shIndentation tests/indent/cases.jsonscripts/update-indent-fixtures.pyscripts/fuzz-indent.pyThe 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.pyThe lines org-toggle-custom-properties-visibilityhides (org-custom-properties)tests/properties/custom.jsonscripts/custom_properties_reference.pyscripts/fuzz-custom-props.pyFormatting (tables, tags) tests/format/cases.jsonscripts/update-format-fixtures.pyscripts/fuzz-format.pyRET ( org-return)tests/return/cases.jsonscripts/update-return-fixtures.pyscripts/fuzz-return.pyM-RET, M-S-RET, C-RET, C-S-RET tests/meta-return/cases.jsonscripts/update-meta-return-fixtures.pyscripts/fuzz-meta-return.pyPromote, demote, move subtrees ( M-<left>,M-S-<right>,M-<up>, …)tests/structure/cases.jsonscripts/update-structure-fixtures.pyscripts/fuzz-structure.pyCut, 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.pyThe line keys ( org-beginning-of-line,org-end-of-line,org-kill-line, on folded headlines too)tests/line/cases.jsonscripts/line_reference.pySorting 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.pyMoving 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.pySparse trees ( org-occur,org-show-todo-tree,org-match-sparse-tree,org-check-deadlinesand the date checks): the matchestests/sparse/cases.jsonscripts/update-sparse-fixtures.pyList items: the meta and shifted keys, C-c C-candC-c ^on an itemtests/lists/cases.jsonscripts/update-lists-fixtures.pyscripts/fuzz-lists.pySetting 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.pyArchiving tests/archive/cases.jsonscripts/update-archive-fixtures.pyscripts/fuzz-archive.pyorg-linttests/lint/cases.jsonscripts/lint_reference.py(andscripts/update-lint-tables.pyfor Org's tables)scripts/fuzz-lint.pyStoring and inserting links ( org-store-link,org-insert-linkand its relatives,org-id-get-create)tests/link/cases.jsonscripts/link_reference.pyscripts/fuzz-link.pyOrg-protocol URLs ( org-protocol.el:org-protocol-check-filename-for-protocol, thestore-link,captureandopen-sourcehandlers)tests/protocol/cases.jsonscripts/update-protocol-fixtures.pyscripts/fuzz-protocol.pyInline tasks ( org-inlinetask:C-c C-x t,M-<left>andM-<right>on one, the motion and the folds around them)tests/inlinetask/cases.jsonscripts/update-inlinetask-fixtures.pyResolving open clocks ( org-resolve-clocks, and beforeorg-clock-in): what is asked, and the filetests/clock/resolve.jsonscripts/clock_resolve_reference.pyDownloading 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.pyBibTeX 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 testruns 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(setNVIMto the binary), on 0.12.0 and stable. Tests run headless against the real server;tests/nvim/scenarios.luais 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 withtmux 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 listvim.ui.selectshows. 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 intests/nvim/typed/NAME.json, steps of the same kind withkeystyped as they are andscreen, text the screen must show.ORG_LSP_NVIM_TESTS=typed scripts/test-nvim.sh [NAME…]runs only the typed ones;ORG_LSP_NVIM_TESTS=headlessruns 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.luamay 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 throughVSCODE_EXECUTABLE, set to itscodiumbinary, not the launcher inbin/). The tests of the extension run first, theneditors/vscode/test/scenarios.test.cjs, its adapter of the scenarios, with each preset's keys (npm test -- scenarios vimruns one). It types everything withxdotoolinto 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 pressesEnter. 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 indir(lsp,vim-lsp,coc.nvimon itsreleasebranch,ale); Vim runs in tmux.editors/vim/test/scenarios.vimis 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.vimtypes 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: whatset_timeoutputs 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-wasip2ineditors/zed; the queries are compiled against the grammar with the tree-sitter CLI; withZEDset, 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 typedORG_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 undertests/whose.organd.org_archivefiles it takes (the recorded agenda cases), andfileson 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_directoryandorg_default_notes_filein the scenario's directory.$DIRin a string is that directory. emacstruefor a scenario that needs Emacs (column view): it is skipped without it, unlessORG_LSP_REQUIRE_EMACSis 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 acount,"below": truefor the keys of modal editors that add below the cursor's line (<Leader><CR>), and theanswersto 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.pyZed'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 scenarioEach 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 tableEXPECTEDnames, which has every token type and modifier of the legend; and a token for each look of the manifest'shighlights.ORG_LSP_HIGHLIGHT_DUMP=1prints 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=highlightfor 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 ofsemanticTokenScopes, 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, intarget/highlight-screenshots, to look at (Zed and Helix have no other check). It readsNVIM,HX,VSCODE_EXECUTABLE,ZEDandZED_ORG_GRAMMAR; VS Code's are taken bynpm 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.0The 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 environmentmarketplace'sAZURE_CLIENT_ID, or the secretVSCE_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 repositoryorg-lsp, the entity Environmentmarketplace. 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:marketplaceandrepo:kristijanhusak/org-lsp:environment:marketplace. - The environment
marketplacein the repository (Settings › Environments), with the secretsAZURE_CLIENT_IDandAZURE_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 askristijanhusak.
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#
- An Eclipse account (https://accounts.eclipse.org/user/register) whose
GitHub Username is
kristijanhusak. - At https://open-vsx.org, log in with GitHub; in Settings › Profile, Log in with Eclipse, then Show Publisher Agreement and Agree (the Eclipse Foundation's Open VSX Publisher Agreement).
- A token: Settings › Access Tokens, Generate New Token.
- The namespace, named as the publisher: in
editors/vscode, afternpm ci,npx ovsx create-namespace kristijanhusak -p TOKEN. To have the extension shown as verified, claim the namespace as the Open VSX wiki says (Namespace Access). - The token as the secret
OVSX_PAT.
Decisions#
- 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).
- Licence. Decided: the GNU GPL, version 3 or later (
LICENSE), as Org mode's:org-coreports Org's own code, which makes it a work based on Org. (MIT before.) - 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 (theorg-lsp/savenotification). Off, they stay unsaved, as in Emacs, andorg.agenda.save(org-save-all-org-buffers,sin the agenda) saves them. - 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.
- A small priority cookie before
COMMENT. Decided:* [#a] COMMENT xis not a commented headline, in every view. Emacs works out:commentedponce, when it is first asked, and keeps it: the agenda and the TODO list ask withcase-fold-searchnil, 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-headingandorg-element-contextdo, andscripts/agenda_reference.pyasks Emacs that way before any view, so the recorded cases agree. - org-protocol. Decided: every server listens for
org-protocolURLs 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 URLfinds the server the user worked in last through the state directory, and a server the editor oforg_protocol_editorstarts 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.