
One of the more frustrating things in software development is when your editor shows different warnings than your CI pipeline. You fix every squiggle in VS Code, push the code, and CI fails on a lint rule your editor did not know about. Or worse, your editor flags something that CI ignores, and you waste time fixing a non-issue.

This happens because the editor integration and the CLI tool are often separate implementations. The VS Code extension has its own logic, the CLI has its own logic, and they drift apart over time. Nobody intends for this to happen. It just does.

When we built Vary's check engine, we wanted to avoid this from the start. The same rule objects that power `vary check` on the command line also power the diagnostics and code actions in the language server. There is no second implementation.

## The check engine as a library

The check engine is not a CLI feature. It is a library that the CLI happens to call.

The engine takes a parsed AST, walks the tree with the configured rule set, and returns diagnostics. Each diagnostic carries a severity, a message, a source location, and optionally an auto-fix.

```
CheckEngine.checkWithSuppressions(context) : CheckResult
```

The CLI calls this function and renders the results to the terminal or as JSON. The language server calls the same function and maps the results to LSP diagnostic objects. The inputs and outputs are the same. Only the rendering is different.

This sounds obvious in retrospect. But the more common pattern, especially in projects that add editor support after the CLI already exists, is to rewrite the analysis logic inside the extension. Sometimes the rewrite is in a different language entirely (TypeScript for VS Code, Kotlin for the CLI). That is where the drift starts.

## DiagnosticsProvider

The language server's `DiagnosticsProvider` is responsible for showing errors and warnings as you type. It already ran the type checker on every file change, which is how type errors appear in your editor without running `vary check` manually.

Adding check engine diagnostics meant calling the check engine after the type checker finishes and appending the results to the diagnostic list. The mapping is direct:

| CheckDiagnostic field | LSP field | Notes |
|---|---|---|
| `severity` | `DiagnosticSeverity` | error, warning, info, hint |
| `range` (line, column) | `Range` | |
| `message` | diagnostic message | |
| `ruleId` | diagnostic code | editor shows `VCI001` next to the message |
| `source` | `"vary-check"` | distinguishes check diagnostics from type errors |

The type checker diagnostics come from `"vary"`. The check engine diagnostics come from `"vary-check"`. In the editor, you can tell at a glance whether a squiggle is a type error or a style issue. This matters because the appropriate response is different: type errors mean the code will not compile, style issues mean it could be cleaner.

## CodeActionProvider

Diagnostics tell you something is wrong. Code actions let you fix it. The LSP protocol defines code actions as edits that an editor can offer in response to a diagnostic, which is the lightbulb menu in VS Code.

For every check diagnostic that has a safe auto-fix, the `CodeActionProvider` creates a code action. It finds relevant `vary-check` diagnostics in the requested range, matches them to the unified diagnostic model, and wraps the fix edits in an LSP `CodeAction`. The action's `kind` is set to `quickfix`, and safe fixes are marked preferred.

The fix itself is a text replacement: an old range and a new string. The check engine computes this during its AST walk. The code action provider just wraps it in the LSP wire format.

Here is what happens when you compare against `None` in a non-idiomatic way:

```vary-snippet
if user == None {   # VCI001: None comparison
    process_missing()
}
```

The editor underlines `user == None` with a suggestion. You hover or click the lightbulb and see:

```
Quick Fix: Replace with 'user is None'
```

Click it, and the line becomes:

```vary-snippet
if user is None {
    process_missing()
}
```

The same edit that `vary check --fix` would have applied on the command line.

## Semantic tokens

While wiring up the check engine, we also connected the semantic token provider. Semantic tokens give the editor richer syntax highlighting than a TextMate grammar can provide alone. The TextMate grammar handles keywords and literals. Semantic tokens add type-aware colouring: function calls highlighted differently from variable references, type annotations coloured as types, imported names resolved to their definitions.

This runs on the same type-checked AST that the check engine uses. Once you have a fully resolved AST, projecting it as semantic tokens is a matter of walking the tree and emitting token types (function, variable, type, parameter, property) with modifiers (declaration, definition, readonly).

We did not plan to do this at the same time as the check engine work. But once the LSP was already consuming the full AST for diagnostics, adding semantic tokens was a small incremental step. That is the nice thing about making the compiler's internal representations available to the language server: each new feature builds on the same foundation.

## The incremental cache

Running the full rule set on every keystroke would be expensive for large files. The CLI check command uses a content-addressed cache: the SHA-256 hash of the file content plus the compiler version and config fingerprint is the cache key. If the file has not changed since the last check, cached diagnostics can be reused immediately.

The LSP does not reuse that CLI cache directly. Instead, it benefits from sharing the same rule engine and diagnostic adapters, which keeps editor diagnostics aligned with CLI output.
