You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Content mappers: go-to-definition on an module specifier returns nothing when the target file has more than one span-map segment #64828
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.tsexport{defaultasCounter}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:
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.
createRangeFromNode(name, file) gives [first token, end of file]. That is the whole virtual text after leading trivia.
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.
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
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".
// 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';constVerbatim=0;constAllFeatures=(1<<20)-1;constconnection=createMessageConnection(newStreamMessageReader(process.stdin),newStreamMessageWriter(process.stdout),);connection.onRequest('initialize',()=>({protocolVersion: 1,positionEncoding: 'utf-16',diagnosticSource: 'foo',}));connection.onRequest('openProject',()=>({}));connection.onRequest('closeProject',()=>{});connection.onRequest('transform',({ content })=>{constmappings=[];letstart=0;for(constpartofcontent.split(/(?<=\n\n)/)){mappings.push([start,part.length,start,part.length,Verbatim,AllFeatures]);start+=part.length;}return{text: content,extension: '.ts', mappings };});connection.listen();
// 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';constroot=fileURLToPath(newURL('.',import.meta.url));consttsc=fileURLToPath(newURL('node_modules/typescript/bin/tsc',import.meta.url));constserver=spawn(process.execPath,[tsc,'--lsp','--stdio'],{cwd: root,stdio: ['pipe','pipe','inherit'],});constconnection=createMessageConnection(newStreamMessageReader(server.stdout),newStreamMessageWriter(server.stdin),);connection.onRequest('client/registerCapability',()=>null);connection.onRequest('workspace/configuration',({ items })=>items.map(()=>null));connection.listen();constrootUri=pathToFileURL(root).href;awaitconnection.sendRequest('initialize',{processId: process.pid,
rootUri,workspaceFolders: [{uri: rootUri,name: 'root'}],initializationOptions: {runExternalCode: true},capabilities: {},});awaitconnection.sendNotification('initialized',{});constfile=`${root}src/index.ts`;consturi=pathToFileURL(file).href;consttext=readFileSync(file,'utf8');awaitconnection.sendNotification('textDocument/didOpen',{textDocument: { uri,languageId: 'typescript',version: 1, text },});/** Line and character of `needle` in index.ts, plus `offset` characters. */functionpositionOf(needle,offset){constbefore=text.slice(0,text.indexOf(needle)+offset);constline=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],]){constresult=awaitconnection.sendRequest('textDocument/definition',{textDocument: { uri },position: positionOf(needle,offset),});constlocations=(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(', ') : '[]'}`);}awaitconnection.sendRequest('shutdown');awaitconnection.sendNotification('exit');connection.dispose();server.kill();
Activity
[-]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[/+]NullVoxPopuli-ai-agent commentedon Oct 19, 2026
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 onimport ... from.Output on
7.1.0-dev.20260822.1through7.1.0-dev.20261019.1:On
7.1.0-dev.20260821.1, all four statements go to the target file.The
.foomapper returns the original text unchanged, as.ts. It splits themappingsarray into Verbatim span mappings at blank lines, with all features on each one.one.foohas no blank line, so it gets one span mapping.two.foohas 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 originalcounter.gts. The ember-content-mapper test fails because itsindex.tsusesexport { 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.jssrc/one.foosrc/two.foosrc/index.tsrepro.js