Skip to content

feat: semantic tokens - #580

Open
briancarbone wants to merge 11 commits into
marko-js:mainfrom
briancarbone:semantic-tokens
Open

feat: semantic tokens#580
briancarbone wants to merge 11 commits into
marko-js:mainfrom
briancarbone:semantic-tokens

Conversation

@briancarbone

Copy link
Copy Markdown

Implements textDocument/semanticTokens (full and range) for Marko documents, and for plain .ts/.js files so embedders without a native TypeScript service get coverage too. Three commits: a standalone fix for processDoc result caching (it never hit, and grew without bound), the feature, and review findings filed in agent-feedback/.

Script regions pass TypeScript's classifier2020 output straight through: the legend mirrors its type indices and modifier bits, and a span only becomes a token if it maps back to exactly one source range with byte-identical text, since the extractor copies source into several generated contexts and anything less strict colors the wrong characters. Marko tags classify from the taglib: user tags as class, core control-flow tags as keyword, other core tags as macro, while native HTML tags stay with the TextMate grammar. Two corrections keep tokens truthful: readonly is stripped from <let> variables (they compile to a generated const), and a mutated tag variable keeps its variable classification over the generated property access.

  • Plugin results merge on one path: overlaps resolve to the earliest plugin, multiline tokens split per line, and the range filter runs after the overlap watermark so full and range requests agree.
  • The server sends workspace/semanticTokens/refresh (debounced 1s, capability-gated) when watched-file or config changes invalidate classifications; cancellation returns RequestCancelled and a request that raced an edit returns ContentModified.
  • Tokens are cached per extraction keyed on projectVersion (repeat requests ~1.4ms; cold classification is ~380ns per generated character), and documents whose generated output exceeds 256KB fall back to grammar highlighting.
  • No delta support and no semanticTokenScopes contribution, matching Volar, Svelte, and Astro.
  • Fixture snapshots decode the wire format into one readable line per token and validate the encoding as they go; token snapshots are opt-in per fixture group so the 182 existing fixtures are untouched.

The review also surfaced pre-existing issues, filed in agent-feedback/ with repro steps: stale parses for untitled documents, stale diagnostics after watched-file changes, the parser swallowing the rest of a file after a close tag like </if >, and a missing cancellation token in the TS host.

Introduced & refined using Fable 5 and Opus 5.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Jul 28, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1a4c5ba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@marko/language-server Minor
@marko/language-tools Patch
marko-vscode Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 86e94dcf-22ee-4111-bbd8-783f95cb0699

📥 Commits

Reviewing files that changed from the base of the PR and between 15978df and 1a4c5ba.

⛔ Files ignored due to path filters (16)
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/single-letter/__snapshots__/single-letter.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/single-letter/__snapshots__/single-letter.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/single-letter/__snapshots__/single-letter.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/single-letter/__snapshots__/single-letter.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-input.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-input.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-input.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-input.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-ro.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-ro.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-ro.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/components/my-ro.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/__snapshots__/writable-vars.expected/index.ts is excluded by !**/__snapshots__/** and included by **
📒 Files selected for processing (12)
  • .changeset/semantic-tokens.md
  • agent-feedback/bugs.md
  • cspell.json
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/single-letter/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/components/my-input.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/components/my-ro.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/writable-vars/index.marko
  • packages/language-server/src/__tests__/plain-script.test.ts
  • packages/language-server/src/__tests__/semantic-tokens.test.ts
  • packages/language-server/src/service/script/index.ts
  • packages/language-server/src/service/semantic-tokens.ts
  • packages/language-tools/src/extractors/script/index.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • packages/language-server/src/tests/fixtures/semantic-tokens/writable-vars/components/my-ro.marko
  • .changeset/semantic-tokens.md
  • packages/language-server/src/tests/fixtures/semantic-tokens/single-letter/index.marko
  • packages/language-server/src/tests/fixtures/semantic-tokens/writable-vars/components/my-input.marko
  • cspell.json
  • packages/language-server/src/tests/semantic-tokens.test.ts
  • packages/language-server/src/service/semantic-tokens.ts
  • packages/language-server/src/tests/fixtures/semantic-tokens/writable-vars/index.marko
  • packages/language-tools/src/extractors/script/index.ts
  • packages/language-server/src/tests/plain-script.test.ts
  • packages/language-server/src/service/script/index.ts

Walkthrough

Adds full and range semantic-token support to the language server. The change defines token legends and plugin contracts, extracts tokens from Marko tags and TypeScript-generated code, merges and encodes plugin results, advertises LSP capabilities, handles cancellation and document changes, and refreshes tokens after updates. It also adds browser exports, semantic-token fixtures, snapshot generation, unit tests, style/core-tag refactors, and related repository feedback notes.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related, but too generic to clearly identify the main change. Use a more specific title like "Add Marko semantic token support" or similar.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly matches the changeset and summarizes the semantic token work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/language-server/src/service/create-service.ts (1)

188-193: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a consistent failure log channel for rejected plugins.

Every other aggregator in this file silently drops rejected plugin results; this is the only one that writes to console.error. If the intent is to surface plugin failures, prefer routing through the server connection logger so stdio transports and client logs stay consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/language-server/src/service/create-service.ts` around lines 188 -
193, Update the rejected-result handling in the results aggregation loop to use
the server connection logger instead of console.error. Preserve the existing
continue behavior and route result.reason through the logger already used by the
surrounding service, ensuring plugin failure output is consistent across
transports.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agent-feedback/bugs.md`:
- Around line 27-31: Clarify the Extracted.sourceRangesAt recommendation in
agent-feedback/bugs.md lines 27-31 and agent-feedback/unclear.md lines 5-9:
document that sourceRangesAt may return multiple ranges and linkedSources
aliases, then state that each consumer must apply validation appropriate to its
required precision rather than universally requiring one exact-width,
byte-identical result. Preserve the warnings about anchor-token expansion and
cross-token spans.

In `@agent-feedback/unclear.md`:
- Around line 11-15: The merge behavior in createService must be documented per
operation rather than justified by a broad “duplicates are harmless” rule. Add
concise comments to the findReferences, doRename, findDocumentHighlights,
findDocumentColors, findDocumentLinks, and findDocumentSymbols merge arms
explaining why each uses rangesOverlap-based deduplication or concatenation,
including the navigation and outline impact of duplicates.

In `@packages/language-server/src/service/types.ts`:
- Around line 81-84: Split the semantic-token contracts around
getSemanticTokens: keep the exported service handler returning SemanticToken[] |
SemanticTokens, but introduce and use a plugin/leaf-provider handler type that
returns only SemanticToken[]. Ensure createService receives the leaf type or
normalizes its result before the Array.isArray(result.value) check so encoded {
data } responses are not silently treated as provider results.

---

Nitpick comments:
In `@packages/language-server/src/service/create-service.ts`:
- Around line 188-193: Update the rejected-result handling in the results
aggregation loop to use the server connection logger instead of console.error.
Preserve the existing continue behavior and route result.reason through the
logger already used by the surrounding service, ensuring plugin failure output
is consistent across transports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a80357e-c08c-48f7-9ee2-31ffe97cb1de

📥 Commits

Reviewing files that changed from the base of the PR and between 832e53c and 8719bee.

⛔ Files ignored due to path filters (36)
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/concise/__snapshots__/concise.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/concise/__snapshots__/concise.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/concise/__snapshots__/concise.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/concise/__snapshots__/concise.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/core-tags/__snapshots__/core-tags.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/core-tags/__snapshots__/core-tags.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/core-tags/__snapshots__/core-tags.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/core-tags/__snapshots__/core-tags.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/components/my-button.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/components/my-button.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/components/my-button.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/components/my-button.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/__snapshots__/custom-tag.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/edge-tags/__snapshots__/edge-tags.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/edge-tags/__snapshots__/edge-tags.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/edge-tags/__snapshots__/edge-tags.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/edge-tags/__snapshots__/edge-tags.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/expressions/__snapshots__/expressions.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/expressions/__snapshots__/expressions.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/expressions/__snapshots__/expressions.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/expressions/__snapshots__/expressions.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/script-blocks/__snapshots__/script-blocks.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/script-blocks/__snapshots__/script-blocks.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/script-blocks/__snapshots__/script-blocks.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/script-blocks/__snapshots__/script-blocks.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/style-block/__snapshots__/style-block.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/style-block/__snapshots__/style-block.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/style-block/__snapshots__/style-block.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/style-block/__snapshots__/style-block.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/tag-params/__snapshots__/tag-params.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/tag-params/__snapshots__/tag-params.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/tag-params/__snapshots__/tag-params.expected/index.tokens.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/tag-params/__snapshots__/tag-params.expected/index.ts is excluded by !**/__snapshots__/** and included by **
📒 Files selected for processing (30)
  • .changeset/semantic-tokens.md
  • agent-feedback/bugs.md
  • agent-feedback/cleanup.md
  • agent-feedback/perf.md
  • agent-feedback/unclear.md
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/concise/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/core-tags/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/components/my-button.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/custom-tag/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/edge-tags/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/expressions/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/script-blocks/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/script-blocks/util.ts
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/style-block/index.marko
  • packages/language-server/src/__tests__/fixtures/semantic-tokens/tag-params/index.marko
  • packages/language-server/src/__tests__/index.test.ts
  • packages/language-server/src/__tests__/semantic-tokens.test.ts
  • packages/language-server/src/__tests__/util/semantic-tokens.ts
  • packages/language-server/src/browser.ts
  • packages/language-server/src/index.ts
  • packages/language-server/src/service/create-service.ts
  • packages/language-server/src/service/marko/index.ts
  • packages/language-server/src/service/marko/semantic-tokens.ts
  • packages/language-server/src/service/marko/util/get-tag-name-completion.ts
  • packages/language-server/src/service/marko/util/is-core-tag.ts
  • packages/language-server/src/service/script/index.ts
  • packages/language-server/src/service/semantic-tokens.ts
  • packages/language-server/src/service/style/index.ts
  • packages/language-server/src/service/types.ts
  • packages/language-server/src/utils/file.ts

Comment thread agent-feedback/bugs.md Outdated
Comment thread agent-feedback/unclear.md Outdated
Comment thread packages/language-server/src/service/types.ts
@DylanPiercey

Copy link
Copy Markdown
Contributor

Thanks for the contribution, @briancarbone — this is a substantial PR and the core design choices are well reasoned. Plugin ordering making taglib classification win overlaps deterministically, the strict single-mapping/exact-width/identical-text filter on TS classifications, and the standalone processDoc caching fix all look right to me.

The review below was generated by Claude Code, not written by me personally. It checked the branch out locally and ran pnpm run build, the test suite (208/208), and lint (eslint + prettier + cspell) — all pass. Note CI itself hasn't run here yet; it needs workflow approval.

Confirmed defects

  1. <let> readonly-stripping is name-keyed and scope-blind. letTagVarNames is a flat Set<string> for the whole document, so readonly is stripped from any matching variable token regardless of scope. Reproduced: with static const value = 1; at the top and an unrelated <let/value = 2/> inside a <for> body, the const reports variable [declaration] instead of variable [declaration readonly]. Removing the nested <let> restores it. Gating on the <let> var's own range rather than the bare name would fix it.

  2. <A/> gets no token, <Ab/> does. localTagNameReg = /^[A-Z][a-zA-Z0-9_$]+$/ requires two or more characters, so single-uppercase-letter component tags fall through the unresolved-tag fallback. +*.

  3. The property-override branch appears to be dead code. Instrumenting existing.type === TokenType.property && type !== TokenType.property and running the full suite yields zero hits across all fixtures. That matches the agent-feedback/bugs.md entry concluding TS emits no classification for a mutated tag variable's assignment target at all — so the special case looks vestigial from before that investigation.

  4. TokenType.string doesn't exist in the legend. The "splits multiline tokens per line" test uses it; it resolves to undefined on both the actual and expected side, so the assertion passes vacuously and the emitted payload contains a non-numeric entry ([0,0,2,null,0]). Harmless in production, but it would be a compile error if tests were type-checked — tsconfig.json has "exclude": ["**/__tests__"].

Worth a decision rather than a fix

  • Custom tags move from entity.name.tag to semantic class, and core tags from support.function.marko to macro — an intended restyle, but a visible color change for every existing Marko file on upgrade, with no semanticTokenScopes contribution as a theme fallback.
  • The plain .ts/.js branch has no test coverage (loadMarkoFiles yields only .marko) and, unlike the Marko path, no result cache, no cancellation check, and no size cap. It's unreachable from the VS Code client, so it only affects the embedders least able to catch it.
  • The ranges.length !== 1 filter silently drops valid tokens somewhat arbitrarily from a user's perspective — e.g. import { greet } is uncolored while both use sites are colored; the MyButton import binding is uncolored while <MyButton/> is.

Minor: decodeTsClassification's 6-bit modifier mask (vs TS's (1 << 8) - 1) is the right call since it protects the legend's deprecated bit — worth a comment saying so deliberately. The tree-walk in getLetTagVarNames duplicates the one in marko/semantic-tokens.ts. And isCoreTag changes getTagNameCompletion behavior as a side effect — a net improvement for nested node_modules, but unflagged.

Items 1 and 2 are the ones I'd want addressed before merge; 3 and 4 are cheap cleanups in the same pass.

@DylanPiercey

Copy link
Copy Markdown
Contributor

Follow-up on item 1, also generated by Claude Code. Digging into the <let> readonly correction specifically: it handles plain <let> correctly, but misses the other writable patterns, and I think the signal it needs is already sitting in the generated output.

Every tag variable is emitted as a const binding — const typed = Marko._.returned(...) — so TypeScript reports readonly on all of them, <let> or not. Writability lives on a separate object: const __marko_internal_change__typed = Marko._.change("typed", "value", ...), and marko.internal.d.ts (the change declaration) makes that property mutable iff a ${SourceName}Change handler resolves, readonly otherwise.

Cross-checking the emitted tokens against doValidate — an assignment in a handler either errors or it doesn't:

Source Assignment legal? Token
<my-input/typed/> (controllable child) yes, no diagnostic readonly ✗ missed
<let/{ a, b } = .../> (destructured) yes, no diagnostic readonly ✗ missed
<let/mutable = 2/> yes not readonly
<my-ro/plain/> (non-controllable) no — "Cannot assign to 'plain' because it is a read-only property" readonly
<const/frozen = 1/> no — same error readonly

So the name allowlist is wrong in both directions:

  • Under-inclusive — a controllable custom tag var (the value/valueChange pattern) is writable in source but still renders readonly, which is the same defect the <let> correction was written to fix. Destructured <let> vars are missed too: identifierReg rejects the pattern text { a, b }, so getLetTagVarNames collects nothing for them.
  • Over-inclusive — the scope collision from my earlier comment: a genuine static const value loses readonly whenever an unrelated <let/value> exists anywhere in the file.

Keying off the __marko_internal_change__<name> property's mutability rather than a tag-name allowlist would cover <let>, controllable tags, destructuring, and future writable patterns uniformly, and is scope-correct by construction so the false positive disappears. Alternatively, having the extractor emit let instead of const for writable tag vars would remove the need for the correction entirely.

This also ties back to item 3: __marko_internal_change__typed.typed = "y" is the actual write site, and it receives no classification at all — which is both why the property-override branch never fires and why the one place a reactive variable is written stays uncolored.

@briancarbone

Copy link
Copy Markdown
Author

Hi, @DylanPiercey! Thanks for the feedback; walked through it with Fable and ran a battery of adversarial reviews to tighten it up. Following this sentence, Claude:

1 + follow-up. Went with your option D: resolve the token's symbol to its Marko._.returned(...) binding and ask the tag's return type for a callable valueChange. It has to be the return type rather than Input, since controllability hinges on the non-optional valueChange the runtime's own let.d.marko casts onto the return. Declaration tokens resolve through the Marko._.hoist alias, so the resolver follows it. All five truth-table rows pass, the static const value collision is fixed, and a corpus sweep changed 47 decisions, each verified intended. Costs ~0.2% of the classification pass. New writable-vars fixture pins it. One flag: an unmutated <let> reports writable; requiring an actual change binding means the internal getCheckFlags API, so tell me if you'd rather have that.

2. Fixed in both the plugin and the extractor, where the widening exposed the real bug: the compilers pair this regex with tag.scope.getBinding(name), and the extractor never did. Ungated, <A href>/<B>/<I> became Cannot find name errors. The extractor now has the binding gate, which also makes unbound PascalCase compiler-faithful (<Missing/> no longer errors in the editor). New single-letter fixture; the changeset now bumps @marko/language-tools.

3. Deleted; reproduced your zero-firings result. The hoist alias's variable span always precedes the property span. Filed in agent-feedback/bugs.md: the destructured change-data expression reads .return.a instead of .return.value.a (coupled to the writability resolver, whose destructured answer is right today only via that bug's any), the mutation-site classification gap, and fallbackTemplate names being emitted unmapped.

4. Real legend entry now; verified the test can fail.

Plain-script branch: cache, 256KB bound, cancellation, plus tests for all three.

Decisions: semanticTokenScopes stays out, because mapping class to a component scope would also recolor genuine TS classes in script regions; this matches what Volar/Svelte/Astro do, but I'm open to it if you disagree. ranges.length !== 1 stays for now; the import-binding cost is real, and I'd like to try admitting multi-range results when all candidates are text-identical as a follow-up.

@DylanPiercey

Copy link
Copy Markdown
Contributor

Non agent speaking (lol):
I haven't taken as close of a look, but I did notice one immediate issue

packages/language-server/src/tests/fixtures/script/class-api-basic/snapshots/class-api-basic.expected/index.md

This has been updated in a bad way, it means that a missing / undefined tag no longer has type errors.

@briancarbone

Copy link
Copy Markdown
Author

You're absolutely right! I mean: I'll hold my agent accountable. What part of "make no mistake" did it fail to understand...

1a4c5ba

@DylanPiercey

Copy link
Copy Markdown
Contributor

@briancarbone 🤣

FYI I appreciate the PR even if just throwing compute/agents at it. But I do generally try to fully review and understand PR's to the Marko ecosystem. Also semantic tokens would be good to have for sure.

Anyways I may sick my agent back on it for another pass but just to let you know we're currently limited by my meat brain capacity.

@briancarbone

Copy link
Copy Markdown
Author

I figured you'd prefer to have Claude read through this PR. Maybe have it take a stab and then compare its plan to this PR. This is to improve the experience where regex is less knowledgeable than an AST and it does make a difference for all editors, although I use Vim personally.

@DylanPiercey

Copy link
Copy Markdown
Contributor

@briancarbone for sure. Just curious are you leveraging https://github.com/marko-js/tree-sitter with vim?

@briancarbone

Copy link
Copy Markdown
Author

No, that's for Neovim, I suppose. I use Vim 9; I've just submitted preliminary syntax highlighting today using that and this as a resource (and passing ~184 tests as prior art). There are some adjustments required still, though. vim/vim@95da62a

@briancarbone

Copy link
Copy Markdown
Author

By the way, if you do toss this PR, the agent-feedback content may be useful to you... Fable surfaced a handful of out-of-scope issues during the planning/research phase.

@DylanPiercey

Copy link
Copy Markdown
Contributor

Yes, agent-feedback has been tremendously useful! Hasn't been a ton of good work on the language-server since I instituted it but its been very useful on the actual marko project.

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.

2 participants