Content mappers

A content mapper is a package a project declares in tsconfig.json that turns otherwise unsupported file content — .vue, .svelte, .astro, a framework's own dialect — into virtual TypeScript while the program is built. Corsa spawns the mapper as a child process, talks JSON-RPC to it over stdio, and parses whatever TypeScript it returns in place of the file's real text.

That substitution is the thing every tool built on corsa-bind has to know about: for a mapped file, the checker's positions are positions in the virtual TypeScript, not in the file the user edits. corsa-bind exposes the mapper identity, both texts, and the span map between them, so results land where the user is looking.

Trusting a workspace

Mappers are external processes, so Corsa refuses to run them unless the caller opts in with --runExternalCode. corsa-bind never sets that by itself.

rust
use corsa::api::ApiSpawnConfig;

// Only for workspaces you trust: this lets configured mappers spawn processes.
let config = ApiSpawnConfig::new("./.cache/corsa").with_run_external_code(true);
ts
const client = CorsaApiClient.spawn({
  executable: "./.cache/corsa",
  runExternalCode: true,
});

In corsa-oxlint the same switch lives at settings.corsaOxlint.parserOptions.corsa.runExternalCode. Leave it unset for untrusted checkouts; the checker then reports the mapped files as unresolved instead of executing anything.

Declaring a mapper

The project names the package and the extensions it claims:

json
{
  "compilerOptions": { "strict": true },
  "contentMappers": [{ "package": "vue-mapper", "extensions": [".vue"] }]
}

The mapper package's own package.json says how to run it:

json
{
  "name": "vue-mapper",
  "version": "1.2.3",
  "typescript": {
    "contentMapper": {
      "exec": ["node", "./mapper.mjs"],
      "compilerOptions": ["jsx", "target"],
      "dynamicConfig": false
    }
  }
}

compilerOptions names the compiler options whose values change the mapper's output; TypeScript folds them into the cache key, so a mapper that declares none is re-run only when a file's content changes. dynamicConfig says the mapper resolves configuration of its own at openProject time and reports the files that invalidate it.

dynamicConfig is a two-way contract on the openProject response, and upstream rejects the project when either side of it is broken (internal/contentmapper/hostimpl.go):

dynamicConfig configIdentity watchedFiles
true required, non-empty optional, absolute paths
false must be absent must be absent

configIdentity is a stable fingerprint of every piece of dynamic configuration that can affect a transform; TypeScript combines it with the mapper identity, its options, and the declared compilerOptions into the cache key, so a mapper that reports the same identity is not re-run. watchedFiles are the files whose changes may alter that identity or the transform output, and must be absolute — a relative entry is rejected as well.

corsa-bind reads the declared mappers back off a parsed config:

rust
let config = client.parse_config_file("./tsconfig.json").await?;
for mapper in config.content_mappers() {
    println!("{} claims {:?}", mapper.package, mapper.extensions);
}
ts
const config = client.parseConfigFile("./tsconfig.json");
for (const mapper of contentMappersFromConfig(config)) {
  console.log(mapper.package, mapper.extensions);
}

Both read the contentMappers array out of the raw tsconfig object the runtime returns alongside the normalized compiler options, and both return an empty list when the project declares none — including on runtimes that predate content mappers.

Writing a mapper

corsa-bind is the consuming side. To author the mapper itself, the ts-content-mapper package wraps the stdio JSON-RPC protocol and gives you a MappedCodeBuilder that records segments as it emits text:

ts
import { MappedCodeBuilder, runContentMapper } from "ts-content-mapper";

const OPEN = "<script>";
const CLOSE = "</script>";

runContentMapper({
  diagnosticSource: "demo",
  transform({ content }) {
    const builder = new MappedCodeBuilder(content);
    const open = content.indexOf(OPEN);
    const end = open < 0 ? -1 : content.indexOf(CLOSE, open + OPEN.length);
    // A file with no complete `<script>` block maps to empty TypeScript rather
    // than to a reversed range: `indexOf` returns -1 on both misses.
    if (end < 0) {
      return builder.build(".ts");
    }
    return builder.appendVerbatim(open + OPEN.length, end).build(".ts");
  },
});

Because the builder merges adjacent verbatim runs, a real mapper usually emits very few, very large segments — the example above produces exactly one for the whole <script> body. appendAtom and appendAlias cover generated text that stands in for an original span, and appendAnchored produces an atom over a zero-length original range, which is how a mapper points a generated declaration at an insertion point. corsa-bind answers queries for all of these, including the zero-length case.

IMPORTANT

The mapper-facing protocol and the API payload share a vocabulary but are not the same wire format. Span map segments are the same tuple on both sides, but diagnostic directives are not: a mapper sends [originalStart, originalLength, virtualStart, virtualEnd, policy, unusedExpectDirectiveIndex?], where the last element indexes a diagnostics list it supplied alongside them, while the payload a client decodes carries [originalStart, originalLength, virtualStart, virtualLength, policy, unusedCode] with that index already resolved to a code. Do not reuse one decoder for both.

Decoding a mapped source file

getSourceFile returns Corsa's binary AST payload. Decoding its source-file level fields tells you whether a mapper produced the file:

rust
let source_file = client
    .get_encoded_source_file(snapshot.handle.clone(), project, "./src/App.vue")
    .await?
    .expect("the project contains the file");

if let Some(mapping) = source_file.content_mapping() {
    println!("{} produced {}", mapping.content_mapper, mapping.virtual_file_name);
    println!("virtual: {}", source_file.text);
    println!("authored: {}", source_file.original_text);
}
ts
const sourceFile = client.getEncodedSourceFile(snapshot.snapshot, project.id, "./src/App.vue");
if (sourceFile?.contentMapping) {
  console.log(sourceFile.contentMapping.contentMapper);
  console.log(sourceFile.text); // virtual TypeScript
  console.log(sourceFile.originalText); // what is on disk
}

The decoded record carries:

Field Meaning
text What the checker parsed — the virtual TypeScript for a mapped file.
originalText What is on disk. Equal to text when no mapper was involved.
contentMapping.contentMapper The mapper's name@version identity.
contentMapping.virtualFileName Filename whose extension decided how the virtual text was parsed.
contentMapping.spanMap Segments relating virtual and original positions.
contentMapping.diagnosticDirectives Ranges where the mapper suppressed or required a diagnostic.
contentMapping.supplementalSourceFileNames Extra outputs the mapper attached to this file.
contentMapping.canonicalSourceFileName Set when this file is itself a supplemental output.

contentMapping is absent for files no mapper touched, which is the cheap way to branch: isContentMappedSourceFile(payload) in JavaScript, EncodedSourceFile::is_content_mapped in Rust.

Decoding is versioned. The payload's protocol byte is checked first, and a payload from a newer binary protocol is reported as an error rather than guessed at — get_source_file keeps returning the raw bytes either way.

Mapping positions

The span map is a list of segments, each relating one half-open virtual range to one half-open original range, in UTF-16 code units. Queries answer in both directions and say how faithful the answer is.

rust
use corsa::api::{SpanMapFeature, SpanMapFidelity, TextRange};

let span_map = source_file.span_map().expect("the file is mapped");

// A checker range -> where to underline in the `.vue` file.
let mapped = span_map.virtual_to_original_span(TextRange::new(13, 18));
assert_eq!(mapped.fidelity, SpanMapFidelity::Exact);

// An editor position -> where to ask the checker.
for projection in span_map.original_to_virtual_positions(16, SpanMapFeature::HOVER) {
    println!("ask the checker at {}", projection.position);
}
ts
const spanMap = spanMapForSourceFile(payload)!;
spanMap.virtualToOriginalSpan(13, 18); // { range: { pos, end }, fidelity }
spanMap.originalToVirtualPositions(16, SpanMap.Feature.Hover);

Three things make this different from a plain offset shift:

Fidelity. A result is Exact when it passed through one verbatim segment, Atom when it landed inside an indivisible segment and had to widen to that segment's bounds, Approximate when its endpoints went through different segments, and None when the input has no counterpart at all — synthesized prologue text the mapper generated, or authored text it never emitted. Only Exact is safe to drive an edit with; SpanMapFidelity::is_single_segment covers Exact and Atom for highlighting.

Features. A mapper may let a segment serve hover but not rename. Pass the feature you are serving and the query skips segments that opted out, returning None fidelity rather than a plausible-looking wrong answer. The bit values are SpanMapFeature in Rust and SpanMap.Feature in JavaScript.

Multiplicity. One piece of authored text can appear several times in the virtual output — a template expression checked once per scope, say. The original-to-virtual queries therefore return a list, ordered by virtual position, and a range that starts in one copy and ends in another yields the smallest candidate around each location instead of one range spanning them all.

corsa-oxlint

corsa-oxlint applies the mapping for you. When the project declares no content mappers — the common case — nothing changes and nothing extra is requested. When it does, files whose extension a mapper claims get their span map resolved once, and the session translates the linter's authored positions into checker positions before every type and symbol lookup. Authored text the mapper never emitted resolves to no type at all, rather than to whatever the checker happens to have at that raw offset.

Rules that need the mapping themselves can read it off the program:

ts
const program = getParserServices(context).program;
const mapping = program.getContentMapping();
if (mapping) {
  const authored = mapping.spanMap.virtualToOriginalPosition(position);
}

program.getContentMappers() returns the mappers the project's tsconfig declares.

Limits

  • Mapper option diagnostics (a mapper rejecting its own configuration) are reported by Corsa through config parsing and are not yet surfaced as a typed field on the parsed-config response.
  • Supplemental outputs are named but not fetched for you; request them with getSourceFile like any other file, and they carry their own span map plus a canonicalSourceFileName pointing back at the file they belong to.
  • The binary source-file decoder reads the source-file level fields only. The node table is still returned untouched as raw bytes.

See also