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.
The redirect decision in fetch_one and the result bookkeeping in
on_fetched each split one decision across two control-flow constructs,
leaving a dead match arm that could only assert its own unreachability.
Deciding in a single match removes both arms and an extra url clone.
Share the root-object $schema member scan between URL detection and
the download lens range, the http(s) scheme test between the fetch
guards, the pointer-identity dedup push between navigation and
expansion, and the temp-dir, canned-response, and loopback-policy test
helpers between the fetch and store test modules. Also go through
Parse::value() like every other call site.
Validation reruns on every change, so the walk paid for keywords that
were not even present: counting characters without a length constraint,
building the present-property set without a required list, cloning the
pattern string on every regex cache hit, and probing every oneOf branch
after two matches had already decided the outcome.
schemaFetch.maxBytes is client-supplied and unclamped, so the over-read
probe's max_bytes + 1 could overflow: a panic that permanently kills
the fetch worker in debug builds, a wrap to take(0) in release.
is_valid failed a probe on any finding, so an advisory format warning
inside an anyOf branch rejected values the schema accepts, made oneOf
undercount matches, inverted not, and steered if/then/else wrong. Only
error findings fail a probe now, matching how external ref warnings
are already kept out of probes via the side channel.
Two known gaps in the schema engine worth more than a quick fix: the
duplicated property/item dispatch between validation and navigation,
and draft 2020-12 $ref sibling composition.
read_message allocated a fresh String per message even though the
stdin reader calls it in a loop. The caller now owns the scratch
buffer, so one allocation serves the whole stream.
write_message flushed after every message over a bare line-buffered
StdoutLock, so an event producing a response, diagnostics, and a code
lens refresh paid the syscall cost per message. Stdout now goes through
a BufWriter and the dispatch loop flushes once per batch.
A malformed value was folded into the missing-header error by
parse().ok(), so the one stderr line the dying reader thread produced
pointed at the wrong cause. And the declared length sized the body
allocation with no bound, letting a corrupt or hostile header force a
giant zeroed allocation before read_exact could fail. Malformed and
oversized values now fail with errors naming the offending value.
The &'static str fields threaded the same "2.0" literal through five
constructors and let a struct literal set a wrong version. A zero-sized
marker that serializes as "2.0" removes both.
No response the server produces carries error data, so every
construction went through ResponseError::new hardcoding None. The
field can return when a response actually needs it.
Missing fields deserialize to None for Option types without
#[serde(default)], and an untagged derive serializes each Outgoing
variant's payload directly, so the manual dispatch match duplicated
what serde generates.
The wrapper had a single caller and forced the callback to fabricate a
BrokenPipe error just to stop reading, which the caller then had to
filter back out. The inline loop returns directly instead.
Serialize-only types carried Option wrappers and skip_serializing_if
attributes on fields that every construction site sets, so the None
path never ran and callers paid for unwraps. Diagnostic.source also
becomes a static str because it is always the constant "json-ls"
and diagnostics are rebuilt on every edit. Option remains only where
a None is actually produced.
Per-field serde renames restate the snake-to-camel convention once
per field and invite a missed rename on future additions. A container
rename_all covers every field, while the LSP "type" fields keep their
explicit rename, which overrides the container attribute.
The URI helper, the completion request params, and the client
capability descent each restated a definition that already existed
elsewhere, namely schema::resolve::file_url_to_path,
TextDocumentPositionParams, and the sibling capability accessor's
traversal. Collapse each onto the single definition and import
completion_item_tag instead of spelling the crate path inline.
Every candidate property rendered its markdown documentation during
the completion request, walking refs and enumerating combinator
variants per item on each keystroke while clients show one item at a
time. Key items now carry the document URI and schema path in their
data payload, and the new completionItem/resolve handler builds the
documentation for the single item the client asks about.
Each conversion re-counted units from the line start, so publishing N
diagnostics on minified single-line JSON cost O(N x line length) per
change. Consecutive conversions on one line now continue from the last
offset, and the resume point resets with the index on every edit.
The key documentation tests in completion each rebuilt the same
document and schema setup to assert describe's rendering one layer
removed. Completion keeps a single wiring test and the rendering
assertions move next to the code in describe.
Hover and completion each walked the tree from an offset to a schema
path, with array branches that had already been copied verbatim and
object branches that had started to drift. The shared walker uses
completion's slot rule, so hovering between a colon and its value now
describes that member's schema instead of the enclosing object's.
A cursor between elements that touched neither fell through to the
element count, so positional schemas (prefixItems, tuple items) matched
the wrong index or none at all.
value_items looked only at the first name in a type array, so
["string", "boolean"] proposed nothing and ordering decided whether
booleans or null showed up.
The parser attaches trivia around a member's colon to the member node,
but fmt_member only re-emitted the key, colon, and value, silently
deleting any comment there and breaking the comment-preserving
contract. Line comments push the value to the next line so it does not
end up commented out.
Keys like $schema and const strings containing a dollar sign were
embedded verbatim in snippet bodies, so client snippet engines parsed
them as variables and inserted the wrong text.
Completing on the key of a member that already has a value replaced
only the key range with the full key-colon-value snippet, turning
{"na": 1} into the invalid {"name": "$0": 1}. The full member
snippet now only applies when the member has neither colon nor value.
Completion collected every ancestor object's keys on descent even when
the cursor lands in a value slot, filtered candidate properties with a
linear scan, and walked array elements twice to find the trailing
index. Duplicate-key detection cloned every key into the seen set even
though the text is only needed when a duplicate is found. All of this
runs on each keystroke, so the waste scales with document size.
Until now the fetch policy could only be set once, through
initializationOptions at startup. Settings now also arrive at runtime,
pushed via workspace/didChangeConfiguration or pulled through a
workspace/configuration request when the client advertises support.
Each update re-layers the runtime settings over the
initializationOptions base, so a setting the user removes reverts to
its startup value instead of sticking.
Redirected schema URLs previously failed with a confusing parse error
on the 3xx body, and silently following them would have stretched the
user's per-URL download approval to targets they never saw. The worker
now walks redirect chains itself (ureq never follows on its own) so
every hop can be vetted and put to the user.
Same-origin hops follow silently in every mode, since the approved
host serves both URLs. Cross-origin hops are governed by a new
schemaFetch.redirects mode: prompt (default) asks the user through
window/showMessageRequest, same-origin refuses with an actionable
error, and always follows silently. maxRedirects stays a pure chain
budget and now defaults to 3. Every hop still passes the scheme and
public-address guards, and a redirect off http(s) is refused so a
remote server cannot turn its redirect into a local file read.
The prompt is the server's first correlated client response: incoming
messages now carry result, and pending prompts are resumed or
abandoned by request id. A paused download stays pending so duplicate
lens clicks cannot start a second chain, and the schema remains keyed
under the URL the document declares wherever the chain leads.
Failures previously went only to stderr, so a user who clicked the
download lens saw nothing happen, and the guard messages that name the
setting to change never reached them. A failed fetch now produces a
window/showMessage error notification.
Only explicit downloads message the user. Automatic probes fail
routinely (nothing cached yet, an unreadable local file) and would
otherwise toast on every open or change, so they stay on stderr.
DefaultHasher's algorithm is unspecified across Rust releases, so a
toolchain bump could silently orphan every cached schema. Hand-rolled
FNV-1a keeps the filenames stable, and a test pins the exact value so
any future change shows up as a deliberate test edit.
Existing cache entries are orphaned once by this change and will be
re-downloaded on the next explicit download.
A $schema URL could point a user-approved download at loopback,
private, or link-local hosts. Resolution and vetting now happen in one
resolver step installed on the ureq agent, so the connection is pinned
to the addresses that were checked. A separate check-then-connect would
let DNS rebinding serve a public address to the check and a private one
to the connection. IPv4-mapped IPv6 addresses are judged by their IPv4
rules so they cannot bypass the range checks.
schemaFetch.allowPrivateNetwork opts out, for schema registries on an
intranet.
Only internal #/... refs are resolved, so any subtree behind an
external $ref validated everything silently, which is the failure mode
users do not notice. Validation now emits one warning per distinct
external reference, anchored at the first node it applies to.
The refs are collected on the side and appended after the walk, because
a finding pushed inside a combinator probe would make is_valid fail
branches that are actually fine.
check_object already gates additionalProperties behind named and
pattern properties, but child_schemas pushed its subschema
unconditionally. Completion and hover under a known property therefore
picked up the additionalProperties schema as well, diverging from the
validator's own logic. Mirror the validator's gating.
JSON Schema patterns use ECMA-262 syntax, and the regex crate rejects
some of it (lookaround, backreferences). A pattern that failed to
compile previously counted as unmatched, so a key covered only by such
a patternProperties entry was wrongly rejected when
additionalProperties is false. Unsupported patterns now conservatively
count as matching, their constraint is skipped, and each failure is
logged to stderr once.
Compiled regexes are also cached instead of being recompiled on every
node visit.
A comment that describes behavior is a second implementation of that
behavior. Only the Rust copy is checked by the compiler and the tests,
so the prose copy drifts and starts lying. Keep a comment only when it
states something the code cannot: a deliberate non-goal, an invariant
the types do not capture, or the reason behind a surprising choice.
Also correct a stale dispatcher comment that claimed no
server-initiated requests are sent. Code lens refresh sends them
fire-and-forget.
In the 'one of:' list, put the first fact on the bullet and the remaining
facts on indented lines beneath it, rather than comma-joining them all on
one line.
Hover navigated with subschemas_for_path, which flattens every anyOf/oneOf
branch into one list, and describe then merged their keywords into a
single nonsensical fact block (a value that is a hex string OR "none" OR
an integer OR an object showed type, enum, pattern, min/max, and required
all at once).
Split navigation from expansion in the matcher: subschemas_for_path keeps
the flattened union for completion, while the new schemas_at_path returns
the leaf schema without expanding its combinators. Make describe
combinator-aware instead: it resolves internal $refs, merges allOf into
one shape, and fans anyOf/oneOf out into a 'one of:' list, each branch on
its own line. Bounded by depth and a variant cap against ref cycles. Key
completion docs go through the same renderer, so a property whose schema
is a union now documents its alternatives too.
Extract the schema fact rendering into features/describe.rs and have both
completion and hover use it, so the hover popup now shows the same fact
list (title, type, assertions, annotations) as key completion instead of
its own smaller set. describe reads each keyword from the first applicable
subschema, so completion passes a single-element slice and hover the full
set from subschemas_for_path.
Mark properties whose subschema has deprecated: true with the LSP
Deprecated completion item tag (strikethrough), negotiated via the
client's completionItem.tagSupport capability, in addition to the existing
deprecated row. Group the completion capability flags into a small struct
to keep Server within the bool-count lint.
Read title, deprecated, readOnly, and writeOnly for the key documentation.
title renders as a bold heading above the description; the three boolean
annotations become fact rows shown only when set to true. These are
annotations, so they do not participate in schema validation.
Extend the key documentation with every scalar assertion the matcher
validates: minimum, maximum, exclusiveMinimum, exclusiveMaximum,
multipleOf, minLength, maxLength, pattern, format, and required. format,
pattern, and required names render without JSON quotes since they name a
constraint rather than carry a value. Applicators are left out because
they describe subschemas, which this path does not resolve.
Render a `type` array as a union (each name in its own inline code span,
joined by |) instead of dropping all but the first entry, and add an
examples row alongside the existing type/enum/const/default facts.
Replace the fenced json blocks with one fact per line, each value in an
inline code span: type, allowed (as a JSON array literal), and default.
This is more compact than a fenced block per value and keeps the quotes on
string values. Inline spans are not syntax-highlighted by token the way a
fenced json block is, trading that coloring for a tighter layout.