Skip to content

Build completions lazily and cut evaluation overhead - #161

Open
jdesrosiers wants to merge 7 commits into
mainfrom
lazy-completions
Open

jdesrosiers wants to merge 7 commits into
mainfrom
lazy-completions

Conversation

@jdesrosiers

@jdesrosiers jdesrosiers commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Completion value sets were built for every instance location during every evaluation, so diagnostics, hover and colors paid for completions on every edit. Evaluation now only records what each schema contributes, and completions are built and combined when they're requested.

  • Lazy value sets. Each location's value set is a memoized thunk that's built the first time something asks for it. Completions, hover, default snippets and deprecated diagnostics share the result.
  • Combine when requested. Completions were merged into every schema on the way up, so an object's property completions were copied once for each $ref and allOf between the schema that declared them and the root. Each schema evaluation now records its own contributions and how its subschemas combine (intersect, union, exclusive union, negate). The completions at a location are combined on request, visiting only the schemas whose instance location contains it. An intermediate commit that combined them in place is superseded by this.
  • Lazy declared properties. The properties keyword records its subschemas instead of adding completions for every declared property each time it's evaluated. A property's completions are created only when it's requested.
  • Cached value sets. The value set built from a schema doesn't depend on the instance, so it's cached by schema location for as long as the compiled schema is in use. A set that depends on the dynamic scope is cached for that scope. One that depends on where a cycle was entered isn't cached. This matters for schemas that reference the same definitions from many places: in the oxlint schema, 868 rules reference the same definition.
  • Cheaper value set building. Keywords that don't constrain values, such as description, are skipped instead of being intersected as unconstrained sets. Building starts from the first constraining keyword instead of an unconstrained set, and the same goes for allOf. The value set iterator now yields members in type order, which completions had relied on the extra intersection for.

Measured against the oxlint configuration schema (765 KB, 871 rules):

Before After
Validation ~87 ms ~0.4 ms (hyperjump alone: ~0.2 ms)
Validation + value completions for one rule ~87 ms ~0.3 ms
Validation + property completions for rules ~87 ms ~3 ms (~5 ms on the first request after compiling)

Behavior change

Cached value sets share annotation objects, and annotations are deduplicated by identity. So a subschema reached more than once at a location that evaluation doesn't reach now contributes its annotations once instead of once per reference. For example, in { "properties": { "foo": { "allOf": [{ "$ref": "#/$defs/a" }, { "$ref": "#/$defs/a" }] } } }, an absent foo now has a's description once instead of twice. Locations that evaluation reaches are unaffected.

Testing

Apart from that, behavior isn't intended to change, so there are no new tests. The existing tests pass. For each step, I compared annotations and completions before and after, and they were identical. The comparisons covered the oxlint schema's objects and their properties, including the order of property names, array items and incomplete locations. For the caching step, they also covered a schema document validated against the 2020-12 meta-schema, which exercises $dynamicRef and recursion. Each comparison was run both on the first query and with the cache warm.

🤖 Generated with Claude Code

jdesrosiers and others added 7 commits October 18, 2026 22:34
The value sets for each instance location were built during every
evaluation, so diagnostics and hover paid for completions on every edit.
They're now memoized thunks that are composed during evaluation and
built the first time completions or annotations at the location are
requested. Validating against the oxlint configuration schema went from
about 87ms to 6ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every keyword that doesn't constrain the value, such as description,
was intersected as an unconstrained set, which allocated a full set and
copied the accumulated one for no effect. They're now skipped, and the
schema's own annotations are added to the accumulated set directly.
Property completions for the rules of the oxlint configuration schema
went from about 40ms to 18ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Building a schema's value set started from an unconstrained set and
intersected every constraining keyword into it, which allocated a full
set and copied the first keyword's set for no effect. The same was true
of allOf. They now start from the first set instead.

Intersecting also put the members in type order, which completions
relied on. The iterator now yields members in type order no matter what
order they were added in.

Property completions for the rules of the oxlint configuration schema
went from about 19ms to 12ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After every keyword, the schema's completions were replaced with a copy
that included the keyword's subschema completions, even for keywords
without subschemas. A schema's completions aren't shared until the
schema is done, so they're now combined in place. Validating against the
oxlint configuration schema went from about 10.5ms to 3.6ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completions were merged into every schema that applied on the way up,
so the completions for an object's properties were copied once for each
$ref and allOf between the schema that declared them and the root. Each
schema evaluation now records what it contributes and how its subschemas
combine, and the completions at a location are combined when they're
requested, visiting only the schemas whose instance location contains
it. Validating against the oxlint configuration schema went from about
3.5ms to 1.8ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The properties keyword added completions for every property it declared
each time it was evaluated, which dominated validation for schemas that
declare a lot of properties. It now records the declared schemas, and a
property's completions are only created when they're requested.
Validating against the oxlint configuration schema went from about
1.8ms to 0.4ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schemas commonly reference the same subschemas from many places, such
as the oxlint configuration schema, where 868 rules reference the same
definition. Its value set was built again for each one. Value sets are
now cached by schema location for as long as the compiled schema is in
use. A set that depends on the dynamic scope is cached for that scope,
and one that depends on where a cycle was entered isn't cached. Property
completions for the rules of the oxlint configuration schema went from
about 15ms to 3ms.

Because cached sets share annotation objects, a subschema reached more
than once at a location that evaluation doesn't reach now contributes
its annotations once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant