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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.
`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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.