@typed/virtual-modules 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"CompilerHostAdapter.d.ts","sourceRoot":"","sources":["../src/CompilerHostAdapter.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,0BAA0B,EAEhC,MAAM,YAAY,CAAC;AASpB,eAAO,MAAM,yBAAyB,GACpC,SAAS,0BAA0B,KAClC,0BA2SF,CAAC"}
1
+ {"version":3,"file":"CompilerHostAdapter.d.ts","sourceRoot":"","sources":["../src/CompilerHostAdapter.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,0BAA0B,EAEhC,MAAM,YAAY,CAAC;AASpB,eAAO,MAAM,yBAAyB,GACpC,SAAS,0BAA0B,KAClC,0BA6SF,CAAC"}
@@ -71,8 +71,9 @@ export const attachCompilerHostAdapter = (options) => {
71
71
  const fallback = originalResolveModuleNames
72
72
  ? originalResolveModuleNames(moduleNames, containingFile, reusedNames, redirectedReference, compilerOptions, containingSourceFile)
73
73
  : moduleNames.map((moduleName) => fallbackResolveModule(moduleName, containingFile, compilerOptions));
74
+ const effectiveImporter = store.resolveEffectiveImporter(containingFile);
74
75
  return moduleNames.map((moduleName, index) => {
75
- const record = getOrBuildRecord(moduleName, containingFile);
76
+ const record = getOrBuildRecord(moduleName, effectiveImporter);
76
77
  if (!record) {
77
78
  return fallback[index];
78
79
  }
@@ -85,8 +86,9 @@ export const attachCompilerHostAdapter = (options) => {
85
86
  : moduleLiterals.map((moduleLiteral) => ({
86
87
  resolvedModule: fallbackResolveModule(moduleLiteral.text, containingFile, compilerOptions),
87
88
  }));
89
+ const effectiveImporter = store.resolveEffectiveImporter(containingFile);
88
90
  return moduleLiterals.map((moduleLiteral, index) => {
89
- const record = getOrBuildRecord(moduleLiteral.text, containingFile);
91
+ const record = getOrBuildRecord(moduleLiteral.text, effectiveImporter);
90
92
  if (!record) {
91
93
  return fallback[index];
92
94
  }
@@ -1 +1 @@
1
- {"version":3,"file":"LanguageServiceAdapter.d.ts","sourceRoot":"","sources":["../src/LanguageServiceAdapter.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,6BAA6B,EAElC,KAAK,0BAA0B,EAChC,MAAM,YAAY,CAAC;AAiGpB,eAAO,MAAM,4BAA4B,GACvC,SAAS,6BAA6B,KACrC,0BA4gBF,CAAC"}
1
+ {"version":3,"file":"LanguageServiceAdapter.d.ts","sourceRoot":"","sources":["../src/LanguageServiceAdapter.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,6BAA6B,EAElC,KAAK,0BAA0B,EAChC,MAAM,YAAY,CAAC;AAiGpB,eAAO,MAAM,4BAA4B,GACvC,SAAS,6BAA6B,KACrC,0BAugBF,CAAC"}
@@ -201,7 +201,7 @@ export const attachLanguageServiceAdapter = (options) => {
201
201
  const r = recordsByVirtualFile.get(parsed.virtualPath);
202
202
  if (r) {
203
203
  effectiveContainingFile = r.virtualFileName;
204
- importerForVirtual = r.importer;
204
+ importerForVirtual = store.resolveEffectiveImporter(parsed.virtualPath);
205
205
  }
206
206
  }
207
207
  else {
@@ -212,6 +212,13 @@ export const attachLanguageServiceAdapter = (options) => {
212
212
  }
213
213
  }
214
214
  }
215
+ else {
216
+ const virtualRecord = recordsByVirtualFile.get(containingFile);
217
+ if (virtualRecord) {
218
+ effectiveContainingFile = virtualRecord.virtualFileName;
219
+ importerForVirtual = store.resolveEffectiveImporter(containingFile);
220
+ }
221
+ }
215
222
  const fallback = originalResolveModuleNames
216
223
  ? originalResolveModuleNames(moduleNames, effectiveContainingFile, reusedNames, redirectedReference, compilerOptions, containingSourceFile)
217
224
  : moduleNames.map((moduleName) => fallbackResolveModule(moduleName, effectiveContainingFile, compilerOptions));
@@ -265,7 +272,7 @@ export const attachLanguageServiceAdapter = (options) => {
265
272
  const r = recordsByVirtualFile.get(parsed.virtualPath);
266
273
  if (r) {
267
274
  effectiveContainingFile = r.virtualFileName;
268
- importerForVirtual = r.importer;
275
+ importerForVirtual = store.resolveEffectiveImporter(parsed.virtualPath);
269
276
  }
270
277
  }
271
278
  else {
@@ -276,6 +283,13 @@ export const attachLanguageServiceAdapter = (options) => {
276
283
  }
277
284
  }
278
285
  }
286
+ else {
287
+ const virtualRecord = recordsByVirtualFile.get(containingFile);
288
+ if (virtualRecord) {
289
+ effectiveContainingFile = virtualRecord.virtualFileName;
290
+ importerForVirtual = store.resolveEffectiveImporter(containingFile);
291
+ }
292
+ }
279
293
  const fallback = originalResolveModuleNameLiterals
280
294
  ? originalResolveModuleNameLiterals(moduleLiterals, effectiveContainingFile, redirectedReference, compilerOptions, containingSourceFile, reusedNames)
281
295
  : moduleLiterals.map((moduleLiteral) => ({
@@ -285,20 +299,6 @@ export const attachLanguageServiceAdapter = (options) => {
285
299
  let hadUnresolvedVirtual = false;
286
300
  const results = moduleLiterals.map((moduleLiteral, index) => {
287
301
  const resolved = getOrBuildRecord(moduleLiteral.text, importerForVirtual);
288
- if (moduleLiteral.text.includes(":")) {
289
- try {
290
- require("node:fs").appendFileSync("/tmp/vm-ts-plugin-debug.log", JSON.stringify({
291
- tag: "LS:resolveLiterals",
292
- id: moduleLiteral.text,
293
- status: resolved.status,
294
- err: resolved.status === "error" ? resolved.diagnostic?.message : undefined,
295
- t: Date.now(),
296
- }) + "\n", { flag: "a" });
297
- }
298
- catch {
299
- /* noop */
300
- }
301
- }
302
302
  if (resolved.status === "resolved") {
303
303
  pendingRetry = false;
304
304
  return {
@@ -55,6 +55,7 @@ export declare function createVirtualRecordStore(options: VirtualRecordStoreOpti
55
55
  flushPendingStale: () => void;
56
56
  resolveRecord: (id: string, importer: string, previous?: MutableVirtualRecord) => ResolveRecordResult;
57
57
  getOrBuildRecord: (id: string, importer: string) => ResolveRecordResult;
58
+ resolveEffectiveImporter: (containingFile: string) => string;
58
59
  dispose: () => void;
59
60
  };
60
61
  //# sourceMappingURL=VirtualRecordStore.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"VirtualRecordStore.d.ts","sourceRoot":"","sources":["../../src/internal/VirtualRecordStore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,KAAK,EACV,2BAA2B,EAC3B,uBAAuB,EACvB,mBAAmB,EACnB,uBAAuB,EACxB,MAAM,aAAa,CAAC;AAGrB,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,mBAAmB,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;CACvC;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,uBAAuB,CAAC;CAC9C;AAED,MAAM,MAAM,mBAAmB,GAC3B,2BAA2B,GAC3B,6BAA6B,GAC7B,wBAAwB,CAAC;AAE7B,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE;QACjB,aAAa,CAAC,OAAO,EAAE,2BAA2B,GAAG,uBAAuB,CAAC;KAC9E,CAAC;IACF,QAAQ,CAAC,wBAAwB,CAAC,EAAE,2BAA2B,CAAC,0BAA0B,CAAC,CAAC;IAC5F,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE;QACnB,SAAS,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,EAAE,CAAC,WAAW,CAAC;QAC/D,cAAc,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,EAAE,SAAS,CAAC,EAAE,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC;KAC1F,CAAC;IACF,oFAAoF;IACpF,QAAQ,CAAC,iBAAiB,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,OAAO,CAAC;IACtE,gFAAgF;IAChF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IACnC,qGAAqG;IACrG,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC9D,uEAAuE;IACvE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,IAAI,CAAC;IACtC,8EAA8E;IAC9E,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IACrC,yGAAyG;IACzG,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACnE,iFAAiF;IACjF,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;CACjE;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,cAAc,YAAY,CAAC,EAClC,QAAQ,EAAE,MAAM,GACf,EAAE,CAAC,kBAAkB,CAMvB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB;;;;;0BAoB5C,oBAAoB,KAAG,IAAI;+BAqBxB,IAAI;+BAYF,oBAAoB,KAAG,IAAI;+BAmD3B,MAAM,KAAG,IAAI;6BAtBjB,IAAI;wBAgD5B,MAAM,YACA,MAAM,aACL,oBAAoB,KAC9B,mBAAmB;2BAsDQ,MAAM,YAAY,MAAM,KAAG,mBAAmB;mBAexD,IAAI;EA8BzB"}
1
+ {"version":3,"file":"VirtualRecordStore.d.ts","sourceRoot":"","sources":["../../src/internal/VirtualRecordStore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,KAAK,EACV,2BAA2B,EAC3B,uBAAuB,EACvB,mBAAmB,EACnB,uBAAuB,EACxB,MAAM,aAAa,CAAC;AAGrB,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,mBAAmB,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;CACvC;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,uBAAuB,CAAC;CAC9C;AAED,MAAM,MAAM,mBAAmB,GAC3B,2BAA2B,GAC3B,6BAA6B,GAC7B,wBAAwB,CAAC;AAE7B,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE;QACjB,aAAa,CAAC,OAAO,EAAE,2BAA2B,GAAG,uBAAuB,CAAC;KAC9E,CAAC;IACF,QAAQ,CAAC,wBAAwB,CAAC,EAAE,2BAA2B,CAAC,0BAA0B,CAAC,CAAC;IAC5F,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE;QACnB,SAAS,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,EAAE,CAAC,WAAW,CAAC;QAC/D,cAAc,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,EAAE,SAAS,CAAC,EAAE,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC;KAC1F,CAAC;IACF,oFAAoF;IACpF,QAAQ,CAAC,iBAAiB,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,OAAO,CAAC;IACtE,gFAAgF;IAChF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IACnC,qGAAqG;IACrG,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC9D,uEAAuE;IACvE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,IAAI,CAAC;IACtC,8EAA8E;IAC9E,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IACrC,yGAAyG;IACzG,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACnE,iFAAiF;IACjF,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;CACjE;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,cAAc,YAAY,CAAC,EAClC,QAAQ,EAAE,MAAM,GACf,EAAE,CAAC,kBAAkB,CAMvB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB;;;;;0BAoB5C,oBAAoB,KAAG,IAAI;+BAqBxB,IAAI;+BAYF,oBAAoB,KAAG,IAAI;+BAmD3B,MAAM,KAAG,IAAI;6BAtBjB,IAAI;wBAgD5B,MAAM,YACA,MAAM,aACL,oBAAoB,KAC9B,mBAAmB;2BAsDQ,MAAM,YAAY,MAAM,KAAG,mBAAmB;+CAqB1B,MAAM,KAAG,MAAM;mBAa7C,IAAI;EA+BzB"}
@@ -168,6 +168,26 @@ export function createVirtualRecordStore(options) {
168
168
  }
169
169
  return resolveRecord(id, importer, existing);
170
170
  };
171
+ /**
172
+ * Walk the virtual-file chain from containingFile back to the root real-file importer.
173
+ * When a virtual module imports another virtual module, the containing file is a virtual
174
+ * file path; plugins must receive the real file as importer. Returns input unchanged if
175
+ * not a virtual file. Handles cycles by breaking the loop.
176
+ */
177
+ const resolveEffectiveImporter = (containingFile) => {
178
+ let current = containingFile;
179
+ const visited = new Set();
180
+ while (true) {
181
+ if (visited.has(current))
182
+ break;
183
+ visited.add(current);
184
+ const record = recordsByVirtualFile.get(current);
185
+ if (!record)
186
+ break;
187
+ current = record.importer;
188
+ }
189
+ return current;
190
+ };
171
191
  const dispose = () => {
172
192
  if (debounceTimer !== undefined) {
173
193
  clearTimeout(debounceTimer);
@@ -194,6 +214,7 @@ export function createVirtualRecordStore(options) {
194
214
  flushPendingStale,
195
215
  resolveRecord,
196
216
  getOrBuildRecord,
217
+ resolveEffectiveImporter,
197
218
  dispose,
198
219
  };
199
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typed/virtual-modules",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,24 +15,24 @@
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },
18
- "scripts": {
19
- "build": "[ -d dist ] || rm -f tsconfig.tsbuildinfo; tsc",
20
- "clean": "rm -rf dist tsconfig.tsbuildinfo",
21
- "test": "vitest run --passWithNoTests",
22
- "test:coverage": "vitest run --coverage"
23
- },
24
18
  "devDependencies": {
25
19
  "@manuth/typescript-languageservice-tester": "^5.0.2",
26
20
  "@types/node": "^25.3.0",
27
21
  "@vitest/coverage-v8": "^4.0.0",
28
- "typescript": "catalog:",
29
- "vitest": "catalog:"
22
+ "typescript": "5.9.3",
23
+ "vitest": "4.0.18"
30
24
  },
31
25
  "peerDependencies": {
32
- "typescript": "catalog:"
26
+ "typescript": "5.9.3"
33
27
  },
34
28
  "files": [
35
29
  "dist",
36
30
  "src"
37
- ]
38
- }
31
+ ],
32
+ "scripts": {
33
+ "build": "[ -d dist ] || rm -f tsconfig.tsbuildinfo; tsc",
34
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
35
+ "test": "vitest run --passWithNoTests",
36
+ "test:coverage": "vitest run --coverage"
37
+ }
38
+ }
@@ -72,6 +72,60 @@ export const value: Foo = { n: 1 };
72
72
  adapter.dispose();
73
73
  });
74
74
 
75
+ it("resolves virtual module that imports another virtual module (virtual-to-virtual)", () => {
76
+ const dir = createTempDir();
77
+ const entry = join(dir, "entry.ts");
78
+ writeFileSync(entry, `import { x } from "virtual:a"; export const out = x;`, "utf8");
79
+
80
+ const receivedImporters: string[] = [];
81
+ const manager = new PluginManager([
82
+ {
83
+ name: "virtual-a",
84
+ shouldResolve: (id) => id === "virtual:a",
85
+ build: (_id, importer) => {
86
+ receivedImporters.push(`a:${importer}`);
87
+ return `import { x } from "virtual:b"; export { x };`;
88
+ },
89
+ },
90
+ {
91
+ name: "virtual-b",
92
+ shouldResolve: (id) => id === "virtual:b",
93
+ build: (_id, importer) => {
94
+ receivedImporters.push(`b:${importer}`);
95
+ return `export const x = 1;`;
96
+ },
97
+ },
98
+ ]);
99
+
100
+ const compilerOptions: ts.CompilerOptions = {
101
+ strict: true,
102
+ noEmit: true,
103
+ target: ts.ScriptTarget.ESNext,
104
+ module: ts.ModuleKind.ESNext,
105
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
106
+ skipLibCheck: true,
107
+ };
108
+ const host = ts.createCompilerHost(compilerOptions);
109
+ attachCompilerHostAdapter({
110
+ ts,
111
+ compilerHost: host,
112
+ resolver: manager,
113
+ projectRoot: dir,
114
+ });
115
+
116
+ const program = ts.createProgram([entry], compilerOptions, host);
117
+ const diagnostics = ts.getPreEmitDiagnostics(program);
118
+ expect(diagnostics).toHaveLength(0);
119
+ expect(receivedImporters).toContain(`a:${entry}`);
120
+ expect(receivedImporters).toContain(`b:${entry}`);
121
+ expect(
122
+ program.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_virtual-a_")),
123
+ ).toBe(true);
124
+ expect(
125
+ program.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_virtual-b_")),
126
+ ).toBe(true);
127
+ });
128
+
75
129
  it("evicts virtual record when importer no longer exists (fileExists returns false)", () => {
76
130
  const dir = createTempDir();
77
131
  const entry1 = join(dir, "entry1.ts");
@@ -140,8 +140,9 @@ export const attachCompilerHostAdapter = (
140
140
  fallbackResolveModule(moduleName, containingFile, compilerOptions),
141
141
  );
142
142
 
143
+ const effectiveImporter = store.resolveEffectiveImporter(containingFile);
143
144
  return moduleNames.map((moduleName, index) => {
144
- const record = getOrBuildRecord(moduleName, containingFile);
145
+ const record = getOrBuildRecord(moduleName, effectiveImporter);
145
146
  if (!record) {
146
147
  return fallback[index];
147
148
  }
@@ -174,8 +175,9 @@ export const attachCompilerHostAdapter = (
174
175
  ),
175
176
  }));
176
177
 
178
+ const effectiveImporter = store.resolveEffectiveImporter(containingFile);
177
179
  return moduleLiterals.map((moduleLiteral, index) => {
178
- const record = getOrBuildRecord(moduleLiteral.text, containingFile);
180
+ const record = getOrBuildRecord(moduleLiteral.text, effectiveImporter);
179
181
  if (!record) {
180
182
  return fallback[index];
181
183
  }
@@ -270,6 +270,12 @@ export const value: Foo = { n: 1 };
270
270
  {
271
271
  file: () => ({ ok: false as const, error: "file-not-in-program" as const }),
272
272
  directory: () => [],
273
+ resolveExport: () => {
274
+ throw new Error("not implemented")
275
+ },
276
+ isAssignableTo: () => {
277
+ throw new Error("not implemented")
278
+ }
273
279
  },
274
280
  { _host: host },
275
281
  ),
@@ -457,6 +463,171 @@ export const value: Foo = { n: 1 };
457
463
  expect(snapshot).toBeUndefined();
458
464
  });
459
465
 
466
+ it("resolves virtual module that imports another virtual module (virtual-to-virtual)", () => {
467
+ const dir = createTempDir();
468
+ const entryFile = join(dir, "entry.ts");
469
+ writeFileSync(
470
+ entryFile,
471
+ `import { x } from "virtual:a"; export const out = x;`,
472
+ "utf8",
473
+ );
474
+
475
+ const receivedImporters: string[] = [];
476
+ const files = new Map<string, { version: number; content: string }>([
477
+ [entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
478
+ ]);
479
+
480
+ const host: ts.LanguageServiceHost = {
481
+ getCompilationSettings: () => ({
482
+ strict: true,
483
+ noEmit: true,
484
+ target: ts.ScriptTarget.ESNext,
485
+ module: ts.ModuleKind.ESNext,
486
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
487
+ skipLibCheck: true,
488
+ }),
489
+ getScriptFileNames: () => [...files.keys()],
490
+ getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
491
+ getScriptSnapshot: (fileName) => {
492
+ const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
493
+ if (!content) return undefined;
494
+ return ts.ScriptSnapshot.fromString(content);
495
+ },
496
+ getCurrentDirectory: () => dir,
497
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
498
+ fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
499
+ readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
500
+ readDirectory: (...args: Parameters<typeof ts.sys.readDirectory>) =>
501
+ ts.sys.readDirectory(...args),
502
+ };
503
+
504
+ const manager = new PluginManager([
505
+ {
506
+ name: "virtual-a",
507
+ shouldResolve: (id) => id === "virtual:a",
508
+ build: (id, importer) => {
509
+ receivedImporters.push(`a:${importer}`);
510
+ return `import { x } from "virtual:b"; export { x };`;
511
+ },
512
+ },
513
+ {
514
+ name: "virtual-b",
515
+ shouldResolve: (id) => id === "virtual:b",
516
+ build: (id, importer) => {
517
+ receivedImporters.push(`b:${importer}`);
518
+ return `export const x = 1;`;
519
+ },
520
+ },
521
+ ]);
522
+
523
+ const languageService = ts.createLanguageService(host);
524
+ attachLanguageServiceAdapter({
525
+ ts,
526
+ languageService,
527
+ languageServiceHost: host,
528
+ resolver: manager,
529
+ projectRoot: dir,
530
+ });
531
+
532
+ const diagnostics = languageService.getSemanticDiagnostics(entryFile);
533
+ expect(diagnostics).toHaveLength(0);
534
+ expect(receivedImporters).toContain(`a:${entryFile}`);
535
+ expect(receivedImporters).toContain(`b:${entryFile}`);
536
+ expect(
537
+ languageService
538
+ .getProgram()
539
+ ?.getSourceFiles()
540
+ .some((sf) => sf.fileName.includes("__virtual_virtual-a_")),
541
+ ).toBe(true);
542
+ expect(
543
+ languageService
544
+ .getProgram()
545
+ ?.getSourceFiles()
546
+ .some((sf) => sf.fileName.includes("__virtual_virtual-b_")),
547
+ ).toBe(true);
548
+ });
549
+
550
+ it("resolves chain virtual:a -> virtual:b -> virtual:c with root importer", () => {
551
+ const dir = createTempDir();
552
+ const entryFile = join(dir, "entry.ts");
553
+ writeFileSync(
554
+ entryFile,
555
+ `import { z } from "virtual:a"; export const out = z;`,
556
+ "utf8",
557
+ );
558
+
559
+ const receivedImporters: string[] = [];
560
+ const files = new Map<string, { version: number; content: string }>([
561
+ [entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
562
+ ]);
563
+
564
+ const host: ts.LanguageServiceHost = {
565
+ getCompilationSettings: () => ({
566
+ strict: true,
567
+ noEmit: true,
568
+ target: ts.ScriptTarget.ESNext,
569
+ module: ts.ModuleKind.ESNext,
570
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
571
+ skipLibCheck: true,
572
+ }),
573
+ getScriptFileNames: () => [...files.keys()],
574
+ getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
575
+ getScriptSnapshot: (fileName) => {
576
+ const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
577
+ if (!content) return undefined;
578
+ return ts.ScriptSnapshot.fromString(content);
579
+ },
580
+ getCurrentDirectory: () => dir,
581
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
582
+ fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
583
+ readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
584
+ readDirectory: (...args: Parameters<typeof ts.sys.readDirectory>) =>
585
+ ts.sys.readDirectory(...args),
586
+ };
587
+
588
+ const manager = new PluginManager([
589
+ {
590
+ name: "virtual-a",
591
+ shouldResolve: (id) => id === "virtual:a",
592
+ build: (_id, importer) => {
593
+ receivedImporters.push(`a:${importer}`);
594
+ return `import { y } from "virtual:b"; export { y as z };`;
595
+ },
596
+ },
597
+ {
598
+ name: "virtual-b",
599
+ shouldResolve: (id) => id === "virtual:b",
600
+ build: (_id, importer) => {
601
+ receivedImporters.push(`b:${importer}`);
602
+ return `import { y } from "virtual:c"; export { y };`;
603
+ },
604
+ },
605
+ {
606
+ name: "virtual-c",
607
+ shouldResolve: (id) => id === "virtual:c",
608
+ build: (_id, importer) => {
609
+ receivedImporters.push(`c:${importer}`);
610
+ return `export const y = 42;`;
611
+ },
612
+ },
613
+ ]);
614
+
615
+ const languageService = ts.createLanguageService(host);
616
+ attachLanguageServiceAdapter({
617
+ ts,
618
+ languageService,
619
+ languageServiceHost: host,
620
+ resolver: manager,
621
+ projectRoot: dir,
622
+ });
623
+
624
+ const diagnostics = languageService.getSemanticDiagnostics(entryFile);
625
+ expect(diagnostics).toHaveLength(0);
626
+ expect(receivedImporters).toContain(`a:${entryFile}`);
627
+ expect(receivedImporters).toContain(`b:${entryFile}`);
628
+ expect(receivedImporters).toContain(`c:${entryFile}`);
629
+ });
630
+
460
631
  it("dispose then getScriptSnapshot does not throw and returns original behavior", () => {
461
632
  const dir = createTempDir();
462
633
  const entryFile = join(dir, "entry.ts");
@@ -281,7 +281,7 @@ export const attachLanguageServiceAdapter = (
281
281
  const r = recordsByVirtualFile.get(parsed.virtualPath);
282
282
  if (r) {
283
283
  effectiveContainingFile = r.virtualFileName;
284
- importerForVirtual = r.importer;
284
+ importerForVirtual = store.resolveEffectiveImporter(parsed.virtualPath);
285
285
  }
286
286
  } else {
287
287
  const r = getOrBuildRecord(parsed.id, parsed.importer);
@@ -290,6 +290,12 @@ export const attachLanguageServiceAdapter = (
290
290
  importerForVirtual = r.record.importer;
291
291
  }
292
292
  }
293
+ } else {
294
+ const virtualRecord = recordsByVirtualFile.get(containingFile);
295
+ if (virtualRecord) {
296
+ effectiveContainingFile = virtualRecord.virtualFileName;
297
+ importerForVirtual = store.resolveEffectiveImporter(containingFile);
298
+ }
293
299
  }
294
300
 
295
301
  const fallback = originalResolveModuleNames
@@ -371,7 +377,7 @@ export const attachLanguageServiceAdapter = (
371
377
  const r = recordsByVirtualFile.get(parsed.virtualPath);
372
378
  if (r) {
373
379
  effectiveContainingFile = r.virtualFileName;
374
- importerForVirtual = r.importer;
380
+ importerForVirtual = store.resolveEffectiveImporter(parsed.virtualPath);
375
381
  }
376
382
  } else {
377
383
  const r = getOrBuildRecord(parsed.id, parsed.importer);
@@ -380,6 +386,12 @@ export const attachLanguageServiceAdapter = (
380
386
  importerForVirtual = r.record.importer;
381
387
  }
382
388
  }
389
+ } else {
390
+ const virtualRecord = recordsByVirtualFile.get(containingFile);
391
+ if (virtualRecord) {
392
+ effectiveContainingFile = virtualRecord.virtualFileName;
393
+ importerForVirtual = store.resolveEffectiveImporter(containingFile);
394
+ }
383
395
  }
384
396
 
385
397
  const fallback: readonly ts.ResolvedModuleWithFailedLookupLocations[] =
@@ -404,23 +416,6 @@ export const attachLanguageServiceAdapter = (
404
416
  let hadUnresolvedVirtual = false;
405
417
  const results = moduleLiterals.map((moduleLiteral, index) => {
406
418
  const resolved = getOrBuildRecord(moduleLiteral.text, importerForVirtual);
407
- if (moduleLiteral.text.includes(":")) {
408
- try {
409
- require("node:fs").appendFileSync(
410
- "/tmp/vm-ts-plugin-debug.log",
411
- JSON.stringify({
412
- tag: "LS:resolveLiterals",
413
- id: moduleLiteral.text,
414
- status: resolved.status,
415
- err: resolved.status === "error" ? (resolved as { diagnostic?: { message?: string } }).diagnostic?.message : undefined,
416
- t: Date.now(),
417
- }) + "\n",
418
- { flag: "a" },
419
- );
420
- } catch {
421
- /* noop */
422
- }
423
- }
424
419
  if (resolved.status === "resolved") {
425
420
  pendingRetry = false;
426
421
  return {
@@ -271,6 +271,25 @@ export function createVirtualRecordStore(options: VirtualRecordStoreOptions) {
271
271
  return resolveRecord(id, importer, existing);
272
272
  };
273
273
 
274
+ /**
275
+ * Walk the virtual-file chain from containingFile back to the root real-file importer.
276
+ * When a virtual module imports another virtual module, the containing file is a virtual
277
+ * file path; plugins must receive the real file as importer. Returns input unchanged if
278
+ * not a virtual file. Handles cycles by breaking the loop.
279
+ */
280
+ const resolveEffectiveImporter = (containingFile: string): string => {
281
+ let current = containingFile;
282
+ const visited = new Set<string>();
283
+ while (true) {
284
+ if (visited.has(current)) break;
285
+ visited.add(current);
286
+ const record = recordsByVirtualFile.get(current);
287
+ if (!record) break;
288
+ current = record.importer;
289
+ }
290
+ return current;
291
+ };
292
+
274
293
  const dispose = (): void => {
275
294
  if (debounceTimer !== undefined) {
276
295
  clearTimeout(debounceTimer);
@@ -299,6 +318,7 @@ export function createVirtualRecordStore(options: VirtualRecordStoreOptions) {
299
318
  flushPendingStale,
300
319
  resolveRecord,
301
320
  getOrBuildRecord,
321
+ resolveEffectiveImporter,
302
322
  dispose,
303
323
  };
304
324
  }