Skip to content

Content mappers: let a mapper opt out of formatting, so tsc --lsp does not offer a formatter that returns no edits #64579

Description

@leonidaz

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" });

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions