Switch to parser-injected heading id + LSP: daily notes, command blocks, and oysterlsp.json - #80
Merged
Merged
Conversation
Parse.of_string now parses with ~heading_auto_ids:true, so the parser
assigns each heading a document-unique identifier and resolves an explicit
{#id} attribute into it. Read it through Parse.Common.heading_id;
Heading_slug's mapper is no longer wired in and nothing stamps its meta key.
The module is kept, with a header recording what it used to mean and how the
two differ: it slugified GitHub-style, mapping every non-alphanumeric byte to
'-' and stripping edges, whereas Cmarkit.Inline.id drops punctuation
instead. Anchors spelled with punctuation therefore change — "Section 1.1"
was section-1-1 and is now section-11 — which is the one promoted
expectation here.
Its inline_to_plain_text moves to Common, dropping the ~ext for wikilinks
that Cmarkit.Inline.to_plain_text now handles itself, and the LSP's slug
comparisons go through Common.heading_id_of_text so they agree with the
identifiers the parser assigns.
Also stop collecting a heading's `Id in Index.extract_attr_ids: the parser
resolves it from the {#id} line above the heading, which the Ext_attributes
case already reports, so the branch — dead until the parser started
producing `Id — would report the anchor twice.
Daily_notes: pure format parsing, path computation, and recognition by tabulating a date window rather than parsing paths back. Config: initializationOptions.dailyNotes -> format / folder / template, kept as strings so Lsp_config.t stays comparable; validation happens on demand, and malformed input falls back to defaults instead of failing initialize. Server: Refactor code actions for today/yesterday/tomorrow plus previous/next existing, and an execute_command returning an open_note intent (uri + optional CreateFile edit) instead of protocol effects. The clock is a dependency (Server.create ?now) so tests do not change meaning overnight. main.ml: executeCommandProvider, Refactor in the advertised kinds, and the intent turned into workspace/applyEdit + window/showDocument; a client without showDocument still gets the note created. An unsupported format silently disables the feature; that gap is marked not-yet-implemented in the spec.
The validation error was computed and discarded, so a typo'd format was indistinguishable from the feature being absent: both offer no action. Server keeps the error from initialize (daily_notes_error) and the adapter sends it once as a window/showMessage of severity Warning. Also fixes the two odoc references the previous commit got wrong.
Three of Lsp_config.t's four fields were unreachable: only dailyNotes could be set, through initializationOptions. A vault-root oysterlsp.json gives them all a home, and gives settings that describe the vault a place that travels with the notes. Sources merge field by field via Lsp_config.Partial, file over client: a file naming only dailyNotes.folder leaves a client-supplied format standing. Schema is shared by both sources. Tolerance unchanged in spirit, wider in reach: a missing file, bad JSON, an unknown key or an unusable value each falls back and is *reported*. Server.daily_notes_error becomes config_warnings, carrying the rejected daily-note format alongside everything else; the adapter sends one window/showMessage per warning at initialize. Read once, at initialize. Watching the file is marked not-yet- implemented in the spec, along with the note that capability-affecting settings could never be live-reloaded anyway. Spec: docs/feature-configuration.mld. Tests: Lsp_config's inline tests for schema/merge/warnings, tests/lsp/test_configuration.ml end to end.
A fenced ```oysterlsp block whose lines name commands. Each line gets a code lens carrying what a code action would, so the command is visible and clickable rather than buried in a menu the client may make awkward to reach — plus a code action on the cursor's line for the keyboard, completion of the names inside the block, and a diagnostic on a name that matches nothing. The block lives in a note rather than at a fixed path, which is what makes daily/prev and daily/next meaningful: they resolve against the host note, so the same block means different things in two daily notes. No new behavior underneath: every line ends at the existing daily-note command. Command_block is the pure layer (finding blocks, reading lines); Server resolves a line against the vault once and all four surfaces share the answer. A line whose command cannot run keeps its lens, without a command, saying why. Dev-time only: the block is an ordinary code block to the parser and the renderer, and is expected to be deleted before a note is published. Spec: docs/feature-command-block.mld.
Completion offered the catalogue inside a block, which only helped someone who already knew the fence to type. A code action closes that circle: "Insert oysterlsp command block", offered anywhere in a note that has none, writing the fence and its lines at the cursor's line. It writes only the commands that resolve in this note, asking what the lenses ask — an ordinary note gets the calendar three, a daily note with neighbours gets all five. Always writing the catalogue would hand most notes two lines the lenses immediately report as dead, and a panel is the operations that matter here. Where nothing resolves at all there is no panel worth writing, and no action. The gate is the note, not the cursor: once a block exists the action stops, since adding a line to a panel is typing, which completion already helps with. One consequence is visible in the daily-note tests, where every note without a block gains an entry in the ordinary menu. Command_block gains the writing side of what it already read: render, and has_command_block, which unlike block_lines can tell an empty block from no block. Spec: docs/feature-command-block.mld, section "Creating a block".
workspace/executeCommand was answered from on_request_unhandled, but linol dispatches ExecuteCommand to a dedicated hook, on_req_execute_command, whose default returns `Null. The branch was unreachable, so every command returned null and neither workspace/applyEdit nor window/showDocument was ever sent: the daily-note commands have never worked through the adapter, from the code-action menu or from a command-block lens. The handler body was correct all along; it hung off the wrong hook. The tests could not see this. They drive Server in process, where execute_command answers correctly; what was broken is the wiring, and main.ml is the one file dune runtest does not reach. So scripts/smoke.py: a real stdio session against a bare vault, asserting what an editor observes — advertised capabilities, actions offered, that a command reaches its handler and produces both protocol effects, and that a command-block line gets a lens carrying a command. It fails on a binary built before this commit and passes after, which is the property that makes it worth keeping. Run by hand after touching main.ml; README says how, and lsp/CLAUDE.md says why.
…the config schema Opening a daily note ends in window/showDocument, which the protocol makes optional and clients need not implement — Zed advertises no such capability and registers no handler for the request. There the whole family was silent: a command that creates at least left a file on disk, since applyEdit is the better-supported half, but daily/today on a note that already exists did nothing at all. Nothing on the server side was wrong, which is what made it hard to see. Say so. A client that cannot focus, or that answers success: false to a request it advertised, now gets a window/showMessage naming the path; the declined case was previously discarded with the rest of the response. A command that finishes in silence is indistinguishable from one that failed — the same lesson as the dead dispatch this command spent a release behind. Offer a route that needs no capability. Insert link to today's daily note writes [[2026-07-26]] at the cursor and creates the note when it is missing, in one edit so the link resolves as it lands; go-to-definition follows it from there. The link is the base name, not the path: resolve_file matches a path subsequence, so it finds 2026/07/2026-07-26.md under a nested format. It is offered everywhere rather than only where focusing fails — a menu that differs per client is unspecifiable, and a link to today's note is an ordinary thing to want. The cost is navigation that writes to the buffer, which is why it is a deliberate action and not a hidden step of the others. Let it be turned off. It is the one action offered in every menu in the vault, wanted there or not, so dailyNotes.linkAction withdraws it without disabling daily notes. Switching it off where showDocument is missing gives up reaching daily notes altogether. Separately, config discoverability. oysterlsp.schema.json publishes the schema the table in feature-configuration specifies; a JSON language server picks it up from a $schema line with no editor configuration and gives completion, validation and per-key documentation. $schema is now accepted and ignored by the parser — an editor association, not a setting, and reporting it as unknown would penalize the one line that makes the file self-describing. --print-default-config writes a starting file with that line in place, meant to be edited rather than kept: a written-down default is frozen, an omitted key follows the server. A published schema is a second statement of what the parser accepts, and two statements drift, so both directions are tested against Config.known_keys — and that inventory is checked against the parser itself by feeding each key a wrong-typed value, since a key it knows complains about the value while an unknown one complains about the key. Verified the check bites: adding a bogus hover.maxLines to the schema fails it. The URL is the raw one on main. A github.com/blob link serves HTML, which a JSON language server cannot read, so that is asserted too; it 404s until this lands on main, and editors fail quietly on an unreachable $schema. smoke.py now runs its whole session twice, with the showDocument capability and without. Only the supporting branch was exercised before, which is not the branch these clients are in. Left as one commit: the three threads interleave in main.ml, lsp_config.ml and both specs, and any split along file lines leaves a commit that does not build.
Two expect tests have been failing since djot extensions were enabled. Both
are on this branch only; main is clean.
The vault_graph one was not a stale expectation. Djot headings run to the
next blank line, so the fixture
"# Section\nsome content"
now names one heading, "Section some content", and [[b#Section]] no longer
matches it — the edge fell back to the note. Promoting that would have
recorded "a heading link resolves to a note" as intended behavior. The
fixtures are what went stale: a blank line under each heading restores what
they were written to test, and the heading link resolves to a Heading target
again, against its original expectation. The self-link fixture shifts by the
one byte the blank line adds.
The html.ml one is genuinely cosmetic: smart punctuation now renders Don't
as Don’t. Promoted.
Multi-line headings are the one djot addition that changes the meaning of
text that was already valid CommonMark, and it took two silent test failures
to notice, so the README's flavor section now says so outright.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three user-facing LSP features, the configuration file they need, and the
parser work underneath them.
Features
Daily notes — code actions to open or create today's / yesterday's /
tomorrow's note, and to step to the previous or next existing one relative
to the note you are in. Formats are a subset of the moment.js tokens and may
contain
/to nest notes in folders. Spec:docs/feature-daily-notes.mld.Command blocks — a fenced
```oysterlspblock whose lines namecommands. Each line gets a code lens carrying what a code action would, so
the command is visible and clickable rather than buried in a menu; plus a
code action on the cursor's line, completion of the names inside the block,
and a diagnostic on a name that matches nothing. The block lives in a note,
which is what makes
daily/prevanddaily/nextmeaningful — they resolveagainst the host note. Dev-time only: an ordinary code block to the parser
and the renderer. Spec:
docs/feature-command-block.mld.Configuration —
oysterlsp.jsonat the vault root, overriding theclient's
initializationOptionskey by key. The file wins because itdescribes the vault, not the editor looking at it. Nothing here can fail
initialization: every fallback is reported as a startup message, since a
setting ignored in silence is indistinguishable from one that does not
exist.
oysterlsp.schema.jsonpublishes the same schema for editors, andoystermark-lsp --print-default-configwrites a starting file. Spec:docs/feature-configuration.mld.Two things worth a reviewer's attention
Commands never reached their handler (
b8d04ca).workspace/executeCommandwas answered from
on_request_unhandled, but linol dispatchesExecuteCommandto a dedicated hook whose default returns
`Null. The branch wasunreachable, so the daily-note commands had never worked through the adapter in
any client. The handler body was correct; it hung off the wrong hook. The
in-process tests could not see it — they drive
Serverdirectly, andmain.mlis the one file
dune runtestdoes not reach — soscripts/smoke.pydrives areal stdio session and asserts what an editor observes. It fails on a binary
built before that commit and passes after.
window/showDocumentis optional, and some editors do not implement it.Focusing a note is the one effect the server cannot perform itself. Where the
capability is missing, an open-only command now reports the path through
window/showMessageinstead of finishing in silence, andInsert link to today's daily noteoffers a route that needs no capability atall: it writes a wikilink, and go-to-definition follows it. That action is the
only one offered in every menu in the vault, so
dailyNotes.linkActioncanwithdraw it.
Underneath
Heading identifiers now come from the parser (
Heading_slugdeprecated), djotextensions are enabled, and anchors resolve uniformly across wikilinks and
CommonMark links — the full 2×3 matrix of {wikilink, markdown link} ×
{heading, block attribute, inline attribute} is pinned by an expect test.
Testing
Inline expect tests beside each module, server-level tests in
tests/lsp/, anda drift test that holds
oysterlsp.schema.jsonand the parser to the same keyset in both directions.
scripts/smoke.pyis a manual session-level check formain.ml, documented in the README.