131 Commits
Author SHA1 Message Date
oscar 90297292e9 chore: remote TODO.md 2026-07-18 22:02:34 +02:00
oscar b6145f2e03 chore: release v1.3.0 v1.3.0 2026-07-18 21:55:59 +02:00
oscar 0875c682a0 feat(schema): reload a changed schema on document open
An update made in one server instance reaches the others through the
shared disk cache the next time a document opens.
2026-07-18 21:55:40 +02:00
oscar 85f54c0c63 chore: release v1.2.0 v1.2.0 2026-07-18 21:09:55 +02:00
oscar e7a7029a34 feat(schema): offer updating a loaded schema 2026-07-18 21:08:56 +02:00
oscar 0e8afc2273 docs: fix formatting 2026-07-04 04:00:04 +02:00
oscar 24215e8f66 chore: release v1.1.0 v1.1.0 2026-07-04 03:54:48 +02:00
oscar 137483807d feat(cli): add command-line options
Parse arguments with lexopt: --cache-dir overrides the on-disk schema
cache location, --offline refuses every network fetch including the
json-ls.downloadSchema command, and --stdio is accepted as a no-op so
editor clients that always pass it keep working. Also wire up --help
and --version.

Offline is enforced at request_download, the sole network entry point,
so no document reaches the network regardless of the fetch policy.
2026-07-04 03:54:06 +02:00
oscar 1350f762e6 chore: release v1.0.0 v1.0.0 2026-07-04 03:20:39 +02:00
oscar d62091c39e docs: clean up README 2026-07-04 03:26:09 +02:00
oscar 192b970436 chore: apply formatting 2026-07-04 03:15:20 +02:00
oscar 85445dca9f docs: restructure prose comments to drop semicolons 2026-07-04 03:06:42 +02:00
oscar e77c331d87 docs(schema): remove semicolons from two comments
The project prose style avoids semicolons. Split the joined clauses in the
file: URL authority comment and the redirect-budget comment into sentences.
2026-07-04 02:58:22 +02:00
oscar cd79629e9f refactor(schema): share the percent-escape decoder
resolve.rs and matcher.rs each walked a string decoding %XX escapes with
their own hex parsing. Extract decode_percent_escape as the shared
primitive. The callers keep their different policies for a malformed
escape: matcher fails the $ref lookup, while resolve passes a stray % in a
path through raw. This also drops the hand-rolled hex_value in favor of
from_str_radix, keeping the explicit hex-digit guard because from_str_radix
would otherwise accept a leading sign.
2026-07-04 02:56:16 +02:00
oscar c20e4b9d7f fix(schema): resolve authority-only base URIs
resolve_uri dropped RFC 3986 5.2.3's rule that a relative reference against
a base with an authority but no path becomes the whole path. A base like
https://ex.com with the relative reference foo produced https://ex.comfoo,
a malformed URL that then failed to fetch. This surfaces when a schema URL
has no path and a server returns a relative Location redirect.
2026-07-04 02:54:51 +02:00
oscar 945a2c18fa build: trim regex to the unicode features in use
Drop regex's default feature set for only the pieces schema pattern
validation relies on: unicode-perl for \d \s \w and word boundaries,
unicode-case for case-insensitive matching, and perf. This sheds the
larger Unicode tables (general categories, scripts, ages, segmentation)
that schema patterns rarely reference. A pattern that does use a dropped
class now fails to compile and falls back to the matcher's existing
permissive handling rather than being validated.
2026-07-04 02:03:11 +02:00
oscar 4ef1712009 refactor(schema): drop the url crate for hand-rolled URI handling
The url crate hard-depended on idna, which since idna 1.0 pulls the whole
ICU stack (icu_normalizer, icu_properties, zerovec, yoke, and five
proc-macro crates), around 24 crates in all, purely to validate
internationalized hostnames. A JSON language server that resolves a
handful of $schema URLs never needs that.

Hand-roll the small surface we actually used: file: URL <-> path
conversion, relative reference resolution, scheme detection, and the
same-origin check that scopes redirect consent. Move ureq from 2 to 3,
where url is an optional dependency rather than a mandatory one, and
re-express the private-network guard as ureq 3's Resolver trait. Vetting
still happens on the exact addresses ureq connects to, so a public DNS
answer cannot be swapped for a private one before the connection.

With both the direct dependency and ureq's transitive one gone, url,
idna, and the ICU stack leave the graph entirely.
2026-07-04 02:02:11 +02:00
oscar 34b1f06871 build: trim dependency features and use thin LTO
Drop ureq's default features and keep only tls, which removes the gzip
decode stack (flate2, miniz_oxide, crc32fast, adler2, simd-adler32) that
schema downloads never relied on.

Switch release LTO from fat to thin. On this workload the two produce an
identically sized binary, but thin roughly halves the release build
(measured 27s to 12s) with no meaningful runtime cost for an IO-bound
language server.
2026-07-04 01:26:01 +02:00
oscar 796a2bf89a fix(schema): stop asserting uri/iri format
The format keyword is annotation-only by default under JSON Schema, so
emitting a diagnostic for a malformed uri or iri was stricter than the
spec's default vocabulary. Asserting it also required parsing every value
as a URL, which is the only reason user-document validation reached for
full idna hostname handling. Treat uri/iri as annotations like every
other unrecognized format.
2026-07-04 01:26:01 +02:00
oscar 169ec2df13 feat(definition): resolve a data key to its schema declaration
Go-to-definition on a property key in a schema-bound document now jumps to
where the schema declares that property. This complements the internal
`$ref` navigation, which the two never overlap by cursor position.

The matcher gains a pointer-tracking navigator that mirrors the existing
value walk but yields JSON Pointers into the schema, expanding combinators
and following internal `$ref`s along the path while leaving the located
declaration unexpanded. The server turns each pointer into a range against
the schema's own source, which for a remote schema is its cache file so the
target is openable.

The schema source is parsed once and cached in the store, keyed like the
in-memory schema and dropped when the schema reloads, so repeated requests
into the same schema neither re-read nor re-parse it.
2026-07-03 23:58:01 +02:00
oscar 8a604710e3 feat(definition): add go-to-definition for internal $ref
Resolve a JSON Schema `$ref` under the cursor against the document's own
tree and point at the referenced node, so authoring a schema can navigate
its `#/...` pointers.

Only internal refs resolve, mirroring the validator: an external ref
returns nothing. Resolution reuses the matcher's percent-decoding and then
applies RFC 6901 token unescaping, so definition lands where validation
would. The feature reads the document tree alone and needs no bound schema.
2026-07-03 22:40:38 +02:00
oscar aafb0aad0b docs(todo): drop completed backlog items
The shared-dispatch refactor and the allocation-free `bind_document`
change have landed, so their backlog entries no longer describe work to
be done.
2026-07-03 22:26:25 +02:00
oscar 0af71c6ee2 fix(schema): compose $ref with its sibling keywords
The validator derefed a `$ref` and replaced the holder outright, so a
schema like `{"$ref": "#/$defs/base", "maxLength": 5}` silently dropped
`maxLength` and every other sibling keyword.

Validate the referenced target and the holder's remaining keywords
together, as draft 2020-12 requires. `deref` still backs navigation, so
only the validation path changes.
2026-07-03 22:26:16 +02:00
oscar 751427b64b perf(schema): avoid re-binding allocations on unchanged documents
`bind_document` runs on every change and unconditionally cloned `doc.uri`
and the detected URL to re-record a mapping that is almost always
identical, then cloned again to re-insert a `waiting` entry that was a
no-op after the first change.

Look entries up before inserting: skip the `doc_schema` write when the
detected URL is unchanged, and only add to `waiting` when the document is
not already recorded there. A keystroke on a bound document now does
lookups without allocating.
2026-07-03 22:24:44 +02:00
oscar 10222eb234 refactor(schema): share property and item dispatch
`child_schemas` re-implemented the same property and array-item dispatch
that `check_object` and `check_array` already encoded, and the copies had
drifted: navigation ignored a boolean `items` schema and applied both
`prefixItems` and `items` to a prefix index instead of letting the prefix
take precedence.

Extract `property_subschemas` and `item_subschemas` as the single source
of which subschemas apply to a given key or index, and have validation and
completion both consume them.
2026-07-03 22:24:34 +02:00
oscar 315bc242b3 fix(server): reject invalid params consistently across handlers
Document symbols, code lens, and code action swallowed a params parse
failure and answered success with an empty result, while formatting,
completion, and hover returned INVALID_PARAMS for the same failure.
All six now return the error, which is what JSON-RPC prescribes and
makes a client bug visible instead of looking like an empty
document.
2026-07-03 08:45:47 +02:00
oscar f4dd8e0e7d perf(server): skip republishing unchanged diagnostics
Every open and change published a diagnostics set even when it was
identical to the last one sent, waking the client's diagnostics
pipeline once per keystroke on a valid document. Remember the last
published set per document and publish only on a difference. The
close-time clearing publish stays unconditional and drops the entry,
so a reopened document always gets a fresh first publish.
2026-07-03 08:45:20 +02:00
oscar a628ffca5b perf(server): reuse the schema binding for diagnostics
bind_schema discarded the schema that bind_document returns, so the
open and change paths resolved the same schema again through
schema_for on every edit. Threading the binding into the diagnostics
computation leaves schema_for to the paths that have no fresh binding
in hand.
2026-07-03 08:43:48 +02:00
oscar 87cc704e25 refactor(document): apply LSP change events directly
The Change struct mirrored TextDocumentContentChangeEvent field for
field, and the only thing constructing it was the dispatcher copying
the wire type over verbatim. Passing the wire type through removes
the parallel definition and the copy loop.
2026-07-03 08:42:58 +02:00
oscar 5bb0547645 fix(server): drop pre-initialize notifications except exit
Requests were already gated on the handshake, but notifications were
routed unconditionally, so a didOpen arriving before initialize ran
under the not-yet-negotiated position encoding and published
diagnostics before the initialize response, both of which the
protocol forbids.
2026-07-03 08:42:11 +02:00
oscar 45e6ca0458 fix(line-index): treat a lone carriage return as a line break
LSP clients count \r, \n, and \r\n as line breaks when numbering
lines. The index only counted \n, so a document with bare \r line
endings read as one long line here while the client saw many, making
every exchanged position wrong and letting incremental edits resolve
to the wrong byte offsets.
2026-07-03 08:41:41 +02:00
oscar 7e9f82fcc4 docs(server): state why closing publishes empty diagnostics
The comment narrated the call below it. The non-obvious fact is that
the protocol has no clear operation, so an empty publish is the only
way to drop the client's diagnostics for a closed document.
2026-07-03 08:26:32 +02:00
oscar ffc521e5b5 test: share server test constructors and open helpers
The request, notification, and server construction helpers existed
byte-identical in both the server unit tests and the integration
tests, the didOpen notification blob was inlined seven times, the
redirect test rebuilt the download sequence download_request already
drives, and the request scan was duplicated at two sites. Move the
shared constructors into a test_support module and route the repeated
sequences through open, download_request, and find_request.

The initialize unit test asserted a strict subset of the integration
capability test plus the serverInfo name, so the name assertion moves
there and the unit test goes away.
2026-07-03 08:26:21 +02:00
oscar c8ea7e6caa refactor(server): extract request and response helpers
The id-allocation plus pending-registration scaffolding for
server-initiated requests was repeated across pull_configuration,
redirect_prompt, and code_lens_refresh, the serialize-and-respond
pair was inlined at seven handler sites next to the existing error
helper, and the parse-params-or-reject match was copied verbatim in
four handlers. Centralize each pattern in one helper so a change to
any of them has a single site.
2026-07-03 08:22:40 +02:00
oscar 2b39570a18 docs(todo): note bind_document allocations on the change path 2026-07-03 08:05:37 +02:00
oscar 7c659b15b9 feat(diagnostics): hold strict-JSON documents to strict JSON
The parser accepts comments and trailing commas unconditionally, which
also meant a plain .json file full of JSONC syntax validated silently.
The languageId the client sends at didOpen now picks the dialect:
documents opened as json get a warning for each comment and trailing
comma, jsonc documents keep accepting both. The dialect only affects
reporting, the parse itself is unchanged, and clients sending unknown
language ids stay on the lenient side.
2026-07-03 07:36:21 +02:00
oscar a8311ad3a8 perf(syntax): store token positions only and slice text from the source
Every token carried an owned String copy of its source slice, one heap
allocation per token on every reparse, and the copy could only ever
agree with the source it was cut from. Tokens now hold just a kind and
range, and consumers slice the document text through Token::text, so
the text lives in exactly one place and the tree becomes plain Copy
data. Accessors that read text (key_text, as_string, as_f64, the
formatter, the validator) take the source alongside the node.
2026-07-03 07:30:13 +02:00
oscar b78c51bcec perf(syntax/tree): borrow unescaped strings when no escapes are present
unescape_string built a fresh String for every call even though most
keys and values contain no escapes, and every key lookup funnels
through it on every reparse. key_text and as_string now return a Cow
that borrows the quote-stripped slice in the common case and only
allocates when an escape actually needs decoding.
2026-07-03 07:08:23 +02:00
oscar ad5c1381d6 perf(syntax/parser): move token text into the tree instead of cloning
eat_trivia and bump cloned each token, String and all, although every
token enters the tree at most once and the buffer is dropped right
after parsing. Taking the text out of the buffer slot avoids one heap
allocation per token per reparse.
2026-07-03 07:04:46 +02:00
oscar 96784e591c refactor(syntax/parser): share the separator handling between containers
The after-item comma-or-closer match in parse_object and parse_array
was the same four arms duplicated, each also repeating its loop's own
end-of-file close error.
2026-07-03 07:04:26 +02:00
oscar d614c0344b refactor(syntax): unify the lexer and parser error types
LexError and SyntaxError were the same range-plus-message pair, and
parse converted one into the other field by field just to merge the
lists. Both stages now report through one SyntaxError in tree.rs.
2026-07-03 07:04:02 +02:00
oscar dc3cc3a44b fix(syntax/lexer): validate numbers against the JSON grammar
Number validity was checked by f64::from_str, which accepts strings
that are not JSON numbers, so leading zeros like 05 and trailing dots
like 5. produced no diagnostic.
2026-07-03 07:02:18 +02:00
oscar 384a3201ee fix(syntax/tree): decode escaped UTF-16 surrogate pairs
char::from_u32 rejects surrogate code points, so both halves of an
escaped pair like \uD83D\uDE00 fell through silently and the astral
character vanished from the decoded string. That corrupted key
matching, enum and const comparison, and $schema detection for any
string spelling a non-BMP character as escapes. Pairs now combine into
the supplementary code point, and lone or mismatched surrogates decode
to the replacement character instead of disappearing.
2026-07-03 07:01:43 +02:00
oscar ca43a3cbef fix(syntax/lexer): stop strings at a backslash-escaped newline
The escape arm consumed whatever byte followed the backslash, so a
backslash at the end of a line swallowed the newline and the lexer ran
into the following lines until the next quote, restructuring the rest
of the document into one string token. The newline guard now applies
inside escapes too.
2026-07-03 07:00:40 +02:00
oscar 07c89a8ac9 perf(syntax/tree): parse unicode escapes without a temporary string
Each \u escape collected its hex digits into a heap String before
parsing. Slicing the digits straight out of the raw input parses the
same text without the per-escape allocation.
2026-07-03 02:13:57 +02:00
oscar 6410350b1f refactor(syntax/tree): make unescape_string private
Only key_text and as_string call it, so the public visibility was
unearned surface.
2026-07-03 02:13:42 +02:00
oscar 94d2a9f195 refactor(syntax/lexer): reuse the error helper and std char width
The unknown-keyword arm pushed a LexError by hand while every other
error path goes through the error helper, and advance_char re-derived
UTF-8 character width from continuation bytes that char::len_utf8
already encodes.
2026-07-03 02:10:04 +02:00
oscar 8c883cc56c fix(schema/store): unbind documents when they close
Nothing removed a closed document from the store, so doc_schema
entries and waiting-set URIs accumulated for the server's lifetime and
a completed fetch nominated long-closed documents for diagnostics.
2026-07-03 01:49:35 +02:00
oscar 14caccf290 perf(schema/store): decide auto-load eligibility once per schema
bind_document ran auto_loadable before consulting auto_attempted, so a
document whose schema never loads paid the workspace-containment
canonicalize() syscalls on every change. Recording the attempt first
also covers ineligible schemas, which previously never entered the set
and were re-probed forever.

A file schema outside one referring document's directory is no longer
retried when a second document in a different directory binds it while
no workspace root is known. That case keeps the explicit download lens.
2026-07-03 01:48:52 +02:00
oscar eb3ffeaafc fix(schema/matcher): percent-decode internal $ref fragments
A $ref fragment is percent-encoded per RFC 3986, so a def whose name
needs encoding, like #/$defs/foo%20bar, never resolved and its
constraints were silently dropped. Decode the fragment before the
pointer lookup, failing malformed escapes instead of passing them raw.
2026-07-03 01:48:28 +02:00