tsc --lsp registers formatting, range formatting and on-type formatting for every content-mapped extension. A mapper can only turn off formatting edits per span (the Formatting span feature), not the registrations. When the mapped language has its own formatter, editors then offer TypeScript 7 as a formatter for those files, and choosing it does nothing.
Use case
TSRX (.tsrx) files are formatted by Prettier with @tsrx/prettier-plugin, and the TSRX language server serves that as textDocument/formatting in every editor. TypeScript cannot format a .tsrx file, because the generated TSX is not the source. So the TSRX mapper sets no Formatting feature on any span, and TypeScript 7 returns no edits:
App.tsrx textDocument/formatting → []
App.tsrx textDocument/rangeFormatting → []
tsc --lsp still registers all three for **/*.tsrx, so:
- VS Code lists TypeScript 7 in Format Document With… for
.tsrx files, with an entry that does nothing. The TSRX extension sets itself as the [tsrx] default formatter to keep Format Document working.
- An editor that runs
tsc --lsp next to the TSRX language server can choose TypeScript 7 for a .tsrx file and format nothing.
What tsc --lsp registers
With the identity mapper from https://gist.github.com/leonidaz/d1fcd2d140269282c7df1d3b84fe517f (.foo files contain TSX, one verbatim span with all features), ask-formatting.mjs (below) shows, on 7.1.0-dev.20260930.4:
content-mapper-formatting (textDocument/formatting) [{"pattern":"**/*.foo"}]
content-mapper-range-formatting (textDocument/rangeFormatting) [{"pattern":"**/*.foo"}]
content-mapper-on-type-formatting (textDocument/onTypeFormatting) [{"pattern":"**/*.foo"}]
For this mapper the registrations are correct: main.foo gets the same formatting edits as the same text in control.ts. The server makes them for every mapped extension (server.go#L650-L670), and a mapper has no way to say that its files have no TypeScript formatting.
Suggestion
Let a mapper declare that TypeScript should not format its files, for example with a field in the mapper's initialize result or in its package's typescript.contentMapper entry. For that mapper's extensions, the server then skips the three formatting registrations. A mapper that declares nothing keeps today's behavior.
Found while adding TypeScript 7 support for .tsrx files (tsrx-org/tsrx#136).
ask-formatting.mjs: put it next to the gist's files, run npm install --ignore-scripts, npm run setup, then node ask-formatting.mjs (it rewrites main.foo and control.ts)
// Records which formatting features `tsc --lsp` registers for content-mapped
// files, and what they return for main.foo and control.ts (same messy text).
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL(".", import.meta.url));
const messy = 'import { greet } from "greeting";\nexport const text =greet( "x" )\n';
writeFileSync(`${root}main.foo`, messy);
writeFileSync(`${root}control.ts`, messy);
const tsc = fileURLToPath(new URL("./node_modules/typescript/bin/tsc", import.meta.url));
const server = spawn(process.execPath, [tsc, "--lsp", "--stdio"], { cwd: root, stdio: ["pipe", "pipe", "inherit"] });
let nextId = 1;
const pending = new Map();
const registrations = [];
let buffered = Buffer.alloc(0);
const send = (message) => {
const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }));
server.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
server.stdin.write(body);
};
const request = (method, params) => new Promise((resolve) => { const id = nextId++; pending.set(id, resolve); send({ id, method, params }); });
server.stdout.on("data", (chunk) => {
buffered = Buffer.concat([buffered, chunk]);
while (true) {
const headerEnd = buffered.indexOf("\r\n\r\n");
if (headerEnd < 0) return;
const length = Number(/Content-Length: (\d+)/i.exec(buffered.subarray(0, headerEnd).toString())[1]);
if (buffered.length < headerEnd + 4 + length) return;
const message = JSON.parse(buffered.subarray(headerEnd + 4, headerEnd + 4 + length).toString());
buffered = buffered.subarray(headerEnd + 4 + length);
if (message.method && message.id !== undefined) {
if (message.method === "client/registerCapability") registrations.push(...message.params.registrations);
const result = message.method === "workspace/configuration" ? message.params.items.map(() => null) : null;
send({ id: message.id, result });
} else if (message.id !== undefined && pending.has(message.id)) {
pending.get(message.id)(message.result ?? message.error);
pending.delete(message.id);
}
}
});
const uri = (file) => pathToFileURL(`${root}${file}`).href;
await request("initialize", {
processId: process.pid,
rootUri: pathToFileURL(root).href,
workspaceFolders: [{ uri: pathToFileURL(root).href, name: "repro" }],
capabilities: {
workspace: { configuration: true, workspaceFolders: true },
textDocument: {
synchronization: { dynamicRegistration: true },
diagnostic: { dynamicRegistration: true },
formatting: { dynamicRegistration: true },
rangeFormatting: { dynamicRegistration: true },
onTypeFormatting: { dynamicRegistration: true },
},
},
initializationOptions: { runExternalCode: true },
});
send({ method: "initialized", params: {} });
const options = { tabSize: 2, insertSpaces: true };
for (const [file, languageId] of [["main.foo", "plaintext"], ["control.ts", "typescript"]]) {
send({ method: "textDocument/didOpen", params: { textDocument: { uri: uri(file), languageId, version: 1, text: messy } } });
await request("textDocument/diagnostic", { textDocument: { uri: uri(file) } });
const formatting = await request("textDocument/formatting", { textDocument: { uri: uri(file) }, options });
console.log(`${file} formatting: ${JSON.stringify(formatting)}`);
}
console.log("content-mapper registrations:");
for (const r of registrations.filter((r) => /format/i.test(r.id))) console.log(` ${r.id} (${r.method}) ${JSON.stringify(r.registerOptions?.documentSelector)}`);
await request("shutdown");
send({ method: "exit" });
tsc --lspregisters formatting, range formatting and on-type formatting for every content-mapped extension. A mapper can only turn off formatting edits per span (theFormattingspan feature), not the registrations. When the mapped language has its own formatter, editors then offer TypeScript 7 as a formatter for those files, and choosing it does nothing.Use case
TSRX (
.tsrx) files are formatted by Prettier with@tsrx/prettier-plugin, and the TSRX language server serves that astextDocument/formattingin every editor. TypeScript cannot format a.tsrxfile, because the generated TSX is not the source. So the TSRX mapper sets noFormattingfeature on any span, and TypeScript 7 returns no edits:tsc --lspstill registers all three for**/*.tsrx, so:.tsrxfiles, with an entry that does nothing. The TSRX extension sets itself as the[tsrx]default formatter to keep Format Document working.tsc --lspnext to the TSRX language server can choose TypeScript 7 for a.tsrxfile and format nothing.What tsc --lsp registers
With the identity mapper from https://gist.github.com/leonidaz/d1fcd2d140269282c7df1d3b84fe517f (
.foofiles contain TSX, one verbatim span with all features),ask-formatting.mjs(below) shows, on7.1.0-dev.20260930.4:For this mapper the registrations are correct:
main.foogets the same formatting edits as the same text incontrol.ts. The server makes them for every mapped extension (server.go#L650-L670), and a mapper has no way to say that its files have no TypeScript formatting.Suggestion
Let a mapper declare that TypeScript should not format its files, for example with a field in the mapper's
initializeresult or in its package'stypescript.contentMapperentry. For that mapper's extensions, the server then skips the three formatting registrations. A mapper that declares nothing keeps today's behavior.Found while adding TypeScript 7 support for
.tsrxfiles (tsrx-org/tsrx#136).ask-formatting.mjs: put it next to the gist's files, runnpm install --ignore-scripts,npm run setup, thennode ask-formatting.mjs(it rewritesmain.fooandcontrol.ts)