Skip to content

Content mappers: go-to-definition on an module specifier returns nothing when the target file has more than one span-map segment #64828

Open

Description

@NullVoxPopuli

Note

Investigated with Claude (via Claude Code); corrections welcome.

🔎 Search Terms

content mapper, go to definition, module specifier, IsSingleSegment

🕗 Version & Regression Information

  • Works: 7.1.0-dev.20260821.1
  • Broken: 7.1.0-dev.20260822.1 through 7.1.0-dev.20261015.1 (bisected on nightlies)
  • The only content-mapper change in that range is Content mappers round 2 #63936 (Content mappers round 2).

💻 Code

Repro in ember-content-mapper (draft PR NullVoxPopuli/ember-content-mapper#41, which is on 20261015.1):

pnpm install
node --test test/lsp.js   # "go to definition from a .ts import into a .gts module"

The test sends textDocument/definition from tsc --lsp --stdio on the specifier in:

// examples/nvp-app/app/components/index.ts
export { default as Counter } from "./counter.gts";
//                                   ^ definition here

🙁 Actual behavior

  • ./counter.gts (has a <template>, so the mapper returns many segments): []
  • A ./plain.gts added next to it (export const plain = 1;, no template, so one verbatim segment): plain.gts at 0:0
  • Go-to-definition on the Counter name in the same line works.

🙂 Expected behavior

Go-to-definition on the specifier goes to the target file, as it does for .ts files and as it did on 20260821.1.

Additional information about the issue

#63936 removed the fallback that returned the top of the file for unmapped results. It said mappers should instead map "a zero-length range at the top of the file with SpanMapFeature.Definition". That doesn't help here, because a zero-length range is never requested:

  1. For a module specifier, the declaration is the target SourceFile. In createDefinitionLocations (tsc/internal/ls/definition.go), GetNameOfDeclaration returns nil, so name is the SourceFile itself.
  2. createRangeFromNode(name, file) gives [first token, end of file]. That is the whole virtual text after leading trivia.
  3. sourceFileRangeToLSPLocationForFeature maps that range, and if !selectionFidelity.IsSingleSegment() { continue } drops it unless one segment covers the whole range.

A transformed file almost always has more than one segment, and segments can't overlap. So a mapper can't make this work: the target is dropped no matter what it emits. We tried mapping the virtual prefix to [0, 0) with Definition, with and without the feature flag, and with zero-length and non-zero virtual spans. All of them returned [].

A possible fix: for a SourceFile declaration in a content-mapped file, use a zero-length range at original position 0. Do not map the whole file's range.

Activity

  1. changed the title [-]Content mappers: go-to-definition on an import specifier returns nothing when the target file has more than one span-map segment[/-] [+]Content mappers: go-to-definition on an module specifier returns nothing when the target file has more than one span-map segment[/+] on Oct 15, 2026
  2. Note

    Investigated with Claude (via Claude Code); corrections welcome.

    Here is a smaller repro, with no Ember and no Glint. It also corrects the trigger in the issue text: the bug shows only on export ... from, not on import ... from.

    npm install
    npm run check   # tsc -p . --runExternalCode: no errors
    npm run repro   # go-to-definition on the module specifier of each line

    Output on 7.1.0-dev.20260822.1 through 7.1.0-dev.20261019.1:

    import { one } from './one.foo';
      ↳ 1 span mapping: src/one.foo {"line":0,"character":0}
    import { two } from './two.foo';
      ↳ 2 span mappings: src/two.foo {"line":0,"character":0}
    export { one as reexportedOne } from './one.foo';
      ↳ 1 span mapping: src/one.foo {"line":0,"character":0}
    export { two as reexportedTwo } from './two.foo';
      ↳ 2 span mappings: []
    

    On 7.1.0-dev.20260821.1, all four statements go to the target file.

    The .foo mapper returns the original text unchanged, as .ts. It splits the mappings array into Verbatim span mappings at blank lines, with all features on each one. one.foo has no blank line, so it gets one span mapping. two.foo has one blank line, so it gets two.

    Correction to the issue text: a target with more than one span mapping is not enough. With import ... from, every mapping shape I tried works, including the full Glint output for the original counter.gts. The ember-content-mapper test fails because its index.ts uses export { default as Counter } from "./counter.gts".

    Repro files (8 files, about 5 KB)

    package.json

    {
      "name": "ts-64828-repro",
      "private": true,
      "type": "module",
      "scripts": {
        "check": "tsc -p . --runExternalCode",
        "repro": "node repro.js"
      },
      "dependencies": {
        "foo-mapper": "file:./foo-mapper",
        "typescript": "7.1.0-dev.20261019.1",
        "vscode-jsonrpc": "^9.0.0"
      }
    }

    tsconfig.json

    {
      "compilerOptions": {
        "strict": true,
        "module": "preserve",
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "noEmit": true
      },
      "include": ["src"],
      "contentMappers": [{ "package": "foo-mapper", "extensions": [".foo"] }]
    }

    foo-mapper/package.json

    {
      "name": "foo-mapper",
      "version": "0.0.0",
      "type": "module",
      "dependencies": {
        "vscode-jsonrpc": "^9.0.0"
      },
      "typescript": {
        "contentMapper": {
          "exec": ["node", "server.js"],
          "compilerOptions": []
        }
      }
    }

    foo-mapper/server.js

    // A content mapper for `.foo` files. The virtual file is the original text,
    // unchanged. The mappings split it into Verbatim spans at blank lines, so
    // one.foo gets one span mapping and two.foo gets two.
    import {
      createMessageConnection,
      StreamMessageReader,
      StreamMessageWriter,
    } from 'vscode-jsonrpc/node';
    
    const Verbatim = 0;
    const AllFeatures = (1 << 20) - 1;
    
    const connection = createMessageConnection(
      new StreamMessageReader(process.stdin),
      new StreamMessageWriter(process.stdout),
    );
    
    connection.onRequest('initialize', () => ({
      protocolVersion: 1,
      positionEncoding: 'utf-16',
      diagnosticSource: 'foo',
    }));
    connection.onRequest('openProject', () => ({}));
    connection.onRequest('closeProject', () => {});
    
    connection.onRequest('transform', ({ content }) => {
      const mappings = [];
      let start = 0;
      for (const part of content.split(/(?<=\n\n)/)) {
        mappings.push([start, part.length, start, part.length, Verbatim, AllFeatures]);
        start += part.length;
      }
      return { text: content, extension: '.ts', mappings };
    });
    
    connection.listen();

    src/one.foo

    export const one = 1;

    src/two.foo

    export const two = 2;
    
    export const alsoTwo = 2;

    src/index.ts

    import { one } from './one.foo';
    import { two } from './two.foo';
    export { one as reexportedOne } from './one.foo';
    export { two as reexportedTwo } from './two.foo';
    
    export { one, two };

    repro.js

    // Starts `tsc --lsp --stdio` and asks for go-to-definition on the module
    // specifiers in src/index.ts. Every target goes through foo-mapper.
    import { spawn } from 'node:child_process';
    import { readFileSync } from 'node:fs';
    import { fileURLToPath, pathToFileURL } from 'node:url';
    
    import {
      createMessageConnection,
      StreamMessageReader,
      StreamMessageWriter,
    } from 'vscode-jsonrpc/node';
    
    const root = fileURLToPath(new URL('.', import.meta.url));
    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'],
    });
    
    const connection = createMessageConnection(
      new StreamMessageReader(server.stdout),
      new StreamMessageWriter(server.stdin),
    );
    connection.onRequest('client/registerCapability', () => null);
    connection.onRequest('workspace/configuration', ({ items }) => items.map(() => null));
    connection.listen();
    
    const rootUri = pathToFileURL(root).href;
    await connection.sendRequest('initialize', {
      processId: process.pid,
      rootUri,
      workspaceFolders: [{ uri: rootUri, name: 'root' }],
      initializationOptions: { runExternalCode: true },
      capabilities: {},
    });
    await connection.sendNotification('initialized', {});
    
    const file = `${root}src/index.ts`;
    const uri = pathToFileURL(file).href;
    const text = readFileSync(file, 'utf8');
    await connection.sendNotification('textDocument/didOpen', {
      textDocument: { uri, languageId: 'typescript', version: 1, text },
    });
    
    /** Line and character of `needle` in index.ts, plus `offset` characters. */
    function positionOf(needle, offset) {
      const before = text.slice(0, text.indexOf(needle) + offset);
      const line = before.split('\n').length - 1;
      return { line, character: before.length - (before.lastIndexOf('\n') + 1) };
    }
    
    for (const [statement, mappings, needle, offset] of [
      ["import { one } from './one.foo';", "1 span mapping", "from './one.foo';\nimport", 7],
      ["import { two } from './two.foo';", "2 span mappings", "from './two.foo';\nexport", 7],
      ["export { one as reexportedOne } from './one.foo';", "1 span mapping", "from './one.foo';\nexport { two", 7],
      ["export { two as reexportedTwo } from './two.foo';", "2 span mappings", "from './two.foo';\n\n", 7],
    ]) {
      const result = await connection.sendRequest('textDocument/definition', {
        textDocument: { uri },
        position: positionOf(needle, offset),
      });
      const locations = (Array.isArray(result) ? result : result ? [result] : []).map(
        (l) => `${(l.uri ?? l.targetUri).replace(rootUri, '')} ${JSON.stringify((l.range ?? l.targetSelectionRange).start)}`,
      );
      console.log(statement);
      console.log(`  ↳ ${mappings}: ${locations.length ? locations.join(', ') : '[]'}`);
    }
    
    await connection.sendRequest('shutdown');
    await connection.sendNotification('exit');
    connection.dispose();
    server.kill();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions