@nifrajs/ts-plugin 3.1.0 → 3.3.0

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.
package/dist/index.cjs CHANGED
@@ -83,6 +83,97 @@ function findRoutesDir(fromFile, exists = import_node_fs.existsSync) {
83
83
  }
84
84
  return;
85
85
  }
86
+ function normalizeProgramPath(fileName) {
87
+ const slashed = fileName.replaceAll("\\", "/");
88
+ const withoutDevicePrefix = slashed.replace(/^\/\/\?\/UNC\//i, "//").replace(/^\/\/\?\//, "");
89
+ return process.platform === "win32" ? withoutDevicePrefix.toLowerCase() : withoutDevicePrefix;
90
+ }
91
+ function canonicalProgramPaths(tsm, fileName) {
92
+ const absolute = import_node_path.resolve(fileName);
93
+ const paths = new Set([normalizeProgramPath(absolute)]);
94
+ const tsmRealpath = tsm.sys.realpath;
95
+ const resolvers = [
96
+ ...typeof tsmRealpath === "function" ? [tsmRealpath] : [],
97
+ import_node_fs.realpathSync.native,
98
+ import_node_fs.realpathSync
99
+ ];
100
+ for (const resolver of resolvers) {
101
+ try {
102
+ paths.add(normalizeProgramPath(resolver(absolute)));
103
+ } catch {}
104
+ }
105
+ return [...paths];
106
+ }
107
+ function typeScriptProgramPath(tsm, program, fileName) {
108
+ const toPath = tsm.toPath;
109
+ if (typeof toPath !== "function")
110
+ return;
111
+ const getCanonicalFileName = tsm.sys.useCaseSensitiveFileNames ? (value) => value : (value) => value.toLowerCase();
112
+ try {
113
+ return toPath(import_node_path.resolve(fileName), program.getCurrentDirectory(), getCanonicalFileName);
114
+ } catch {
115
+ return;
116
+ }
117
+ }
118
+ function filesystemIdentity(fileName) {
119
+ try {
120
+ const info = import_node_fs.statSync(import_node_path.resolve(fileName), { bigint: true });
121
+ if (info.ino === 0n)
122
+ return;
123
+ return `bigint:${info.dev}:${info.ino}`;
124
+ } catch {
125
+ try {
126
+ const info = import_node_fs.statSync(import_node_path.resolve(fileName));
127
+ if (info.ino === 0)
128
+ return;
129
+ return `number:${info.dev}:${info.ino}`;
130
+ } catch {
131
+ return;
132
+ }
133
+ }
134
+ }
135
+ function programSourceFile(tsm, program, fileName) {
136
+ if (program === undefined)
137
+ return;
138
+ const direct = program.getSourceFile(fileName);
139
+ if (direct !== undefined)
140
+ return direct;
141
+ const programPath = typeScriptProgramPath(tsm, program, fileName);
142
+ if (programPath !== undefined) {
143
+ const byTypeScriptPath = program.getSourceFileByPath(programPath);
144
+ if (byTypeScriptPath !== undefined)
145
+ return byTypeScriptPath;
146
+ }
147
+ const wanted = new Set(canonicalProgramPaths(tsm, fileName));
148
+ const byPath = program.getSourceFiles().find((source) => canonicalProgramPaths(tsm, source.fileName).some((path) => wanted.has(path)));
149
+ if (byPath !== undefined)
150
+ return byPath;
151
+ const wantedIdentity = filesystemIdentity(fileName);
152
+ if (wantedIdentity === undefined)
153
+ return;
154
+ return program.getSourceFiles().find((source) => filesystemIdentity(source.fileName) === wantedIdentity);
155
+ }
156
+ function hostSourceFile(tsm, host, program, fileName) {
157
+ if (host === undefined)
158
+ return;
159
+ const snapshot = host.getScriptSnapshot(fileName);
160
+ if (snapshot === undefined)
161
+ return;
162
+ const text = snapshot.getText(0, snapshot.getLength());
163
+ const options = program?.getCompilerOptions();
164
+ const getScriptKindFromFileName = tsm.getScriptKindFromFileName;
165
+ return tsm.createSourceFile(fileName, text, options?.target ?? tsm.ScriptTarget.Latest, true, host.getScriptKind?.(fileName) ?? (typeof getScriptKindFromFileName === "function" ? getScriptKindFromFileName(fileName) : tsm.ScriptKind.TSX));
166
+ }
167
+ function diskSourceFile(tsm, program, fileName) {
168
+ let text;
169
+ try {
170
+ text = import_node_fs.readFileSync(fileName, "utf8");
171
+ } catch {
172
+ return;
173
+ }
174
+ const getScriptKindFromFileName = tsm.getScriptKindFromFileName;
175
+ return tsm.createSourceFile(fileName, text, program?.getCompilerOptions().target ?? tsm.ScriptTarget.Latest, true, typeof getScriptKindFromFileName === "function" ? getScriptKindFromFileName(fileName) : tsm.ScriptKind.TSX);
176
+ }
86
177
  function innermostNodeAt(tsm, source, node, position) {
87
178
  if (position < node.getStart(source) || position > node.getEnd())
88
179
  return;
@@ -103,8 +194,9 @@ function findRoutePathLiteral(tsm, source, position) {
103
194
  const start = node.getStart(source) + 1;
104
195
  return { text: node.text, span: { start, length: Math.max(0, node.getWidth(source) - 2) } };
105
196
  }
106
- function routeDefinitionAt(tsm, ls, fileName, position) {
107
- const source = ls.getProgram()?.getSourceFile(fileName);
197
+ function routeDefinitionAt(tsm, ls, host, fileName, position) {
198
+ const program = ls.getProgram();
199
+ const source = programSourceFile(tsm, program, fileName) ?? hostSourceFile(tsm, host, program, fileName) ?? diskSourceFile(tsm, program, fileName);
108
200
  if (source === undefined)
109
201
  return;
110
202
  const literal = findRoutePathLiteral(tsm, source, position);
@@ -147,9 +239,18 @@ function init(modules) {
147
239
  const writable = proxy;
148
240
  for (const key of Object.keys(ls)) {
149
241
  const member = ls[key];
150
- writable[key] = typeof member === "function" ? member.bind(ls) : member;
242
+ writable[key] = typeof member === "function" ? (...args) => {
243
+ const fileName = args[0];
244
+ const source = typeof fileName === "string" ? programSourceFile(tsm, ls.getProgram(), fileName) : undefined;
245
+ return member.apply(ls, [
246
+ ...source === undefined ? args : [source.fileName, ...args.slice(1)]
247
+ ]);
248
+ } : member;
151
249
  }
152
- proxy.getDefinitionAndBoundSpan = (fileName, position) => routeDefinitionAt(tsm, ls, fileName, position) ?? ls.getDefinitionAndBoundSpan(fileName, position);
250
+ proxy.getDefinitionAndBoundSpan = (fileName, position) => routeDefinitionAt(tsm, ls, info.languageServiceHost, fileName, position) ?? (() => {
251
+ const source = programSourceFile(tsm, ls.getProgram(), fileName);
252
+ return source === undefined ? undefined : ls.getDefinitionAndBoundSpan(source.fileName, position);
253
+ })();
153
254
  return proxy;
154
255
  }
155
256
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAA;AAGrC,sGAAsG;AACtG,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAoB,GAC1C,MAAM,GAAG,SAAS,CASpB;AAkBD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EAAE,EACd,MAAM,EAAE,EAAE,CAAC,UAAU,EACrB,QAAQ,EAAE,MAAM,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAA;CAAE,GAAG,SAAS,CAMjD;AAuCD,iBAAS,IAAI,CAAC,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,EAAE,CAAA;CAAE,GAAG,EAAE,CAAC,MAAM,CAAC,YAAY,CAuBxE;AAED,eAAe,IAAI,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAA;AAGrC,sGAAsG;AACtG,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAoB,GAC1C,MAAM,GAAG,SAAS,CASpB;AAgMD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EAAE,EACd,MAAM,EAAE,EAAE,CAAC,UAAU,EACrB,QAAQ,EAAE,MAAM,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAA;CAAE,GAAG,SAAS,CAMjD;AA4CD,iBAAS,IAAI,CAAC,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,EAAE,CAAA;CAAE,GAAG,EAAE,CAAC,MAAM,CAAC,YAAY,CA6CxE;AAED,eAAe,IAAI,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/ts-plugin",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "TypeScript language-service plugin for nifra - go-to-definition from a route-path string literal to the routes/ file that serves it.",
5
5
  "keywords": [
6
6
  "bun",
@@ -44,8 +44,8 @@
44
44
  "build": "bun run build.ts"
45
45
  },
46
46
  "dependencies": {
47
- "@nifrajs/core": "3.1.0",
48
- "@nifrajs/web": "3.1.0"
47
+ "@nifrajs/core": "3.3.0",
48
+ "@nifrajs/web": "3.3.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "typescript": ">=5.5.0"
package/src/index.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  * matches with `@nifrajs/core`'s pattern matcher (see ./resolve.ts), so a path resolves to exactly the
13
13
  * file it would serve at runtime. The plugin only wires that into `getDefinitionAndBoundSpan`.
14
14
  */
15
- import { existsSync } from "node:fs"
16
- import { dirname, isAbsolute, join } from "node:path"
15
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
16
+ import { dirname, isAbsolute, join, resolve } from "node:path"
17
17
  import { discoverRoutes } from "@nifrajs/web/fs"
18
18
  import type * as ts from "typescript"
19
19
  import { resolveRouteFile } from "./resolve.ts"
@@ -33,6 +33,180 @@ export function findRoutesDir(
33
33
  return undefined
34
34
  }
35
35
 
36
+ /**
37
+ * TypeScript can canonicalize a file through its host while the editor hands the plugin a different
38
+ * spelling. Windows CI exposes this as the short `RUNNER~1` temp path versus the long path stored in
39
+ * the Program. Match by filesystem identity before delegating to TypeScript; a failed realpath falls
40
+ * back to the lexical absolute path so a missing or synthetic file is still handled normally.
41
+ */
42
+ function normalizeProgramPath(fileName: string): string {
43
+ const slashed = fileName.replaceAll("\\", "/")
44
+ const withoutDevicePrefix = slashed.replace(/^\/\/\?\/UNC\//i, "//").replace(/^\/\/\?\//, "")
45
+ return process.platform === "win32" ? withoutDevicePrefix.toLowerCase() : withoutDevicePrefix
46
+ }
47
+
48
+ /** Keep every spelling a filesystem resolver may return; native and portable realpath disagree on
49
+ * some Windows short-name paths, so matching only one of them is still a false negative. */
50
+ function canonicalProgramPaths(tsm: typeof ts, fileName: string): readonly string[] {
51
+ const absolute = resolve(fileName)
52
+ const paths = new Set<string>([normalizeProgramPath(absolute)])
53
+ const tsmRealpath = tsm.sys.realpath
54
+ const resolvers = [
55
+ ...(typeof tsmRealpath === "function" ? [tsmRealpath] : []),
56
+ realpathSync.native,
57
+ realpathSync,
58
+ ]
59
+ for (const resolver of resolvers) {
60
+ try {
61
+ paths.add(normalizeProgramPath(resolver(absolute)))
62
+ } catch {
63
+ // Synthetic editor files and a path removed during an edit have no realpath. The absolute
64
+ // spelling remains a valid candidate for TypeScript's virtual source overlay.
65
+ }
66
+ }
67
+ return [...paths]
68
+ }
69
+
70
+ /**
71
+ * TypeScript keeps an internal, canonical `Path` for every source file. Prefer that lookup when it
72
+ * is available: unlike a direct string comparison it applies the same case/normalization rules the
73
+ * Program used while loading the file. This is important on Windows, where the language service can
74
+ * receive an 8.3 spelling (`RUNNER~1`) while the Program was populated from the long spelling.
75
+ */
76
+ function typeScriptProgramPath(
77
+ tsm: typeof ts,
78
+ program: ts.Program,
79
+ fileName: string,
80
+ ): ts.Path | undefined {
81
+ const toPath = (
82
+ tsm as unknown as {
83
+ toPath?: (
84
+ fileName: string,
85
+ currentDirectory: string,
86
+ getCanonicalFileName: (fileName: string) => string,
87
+ ) => ts.Path
88
+ }
89
+ ).toPath
90
+ if (typeof toPath !== "function") return undefined
91
+ const getCanonicalFileName = tsm.sys.useCaseSensitiveFileNames
92
+ ? (value: string): string => value
93
+ : (value: string): string => value.toLowerCase()
94
+ try {
95
+ return toPath(resolve(fileName), program.getCurrentDirectory(), getCanonicalFileName)
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+
101
+ /** Match a real file even when Windows handed the editor an 8.3 alias for the same path. */
102
+ function filesystemIdentity(fileName: string): string | undefined {
103
+ try {
104
+ const info = statSync(resolve(fileName), { bigint: true })
105
+ if (info.ino === 0n) return undefined
106
+ return `bigint:${info.dev}:${info.ino}`
107
+ } catch {
108
+ try {
109
+ const info = statSync(resolve(fileName))
110
+ if (info.ino === 0) return undefined
111
+ return `number:${info.dev}:${info.ino}`
112
+ } catch {
113
+ // TypeScript also exposes virtual/source-overlay files. They have no filesystem identity, so
114
+ // the lexical/realpath checks remain the correct fallback for those inputs.
115
+ return undefined
116
+ }
117
+ }
118
+ }
119
+
120
+ function programSourceFile(
121
+ tsm: typeof ts,
122
+ program: ts.Program | undefined,
123
+ fileName: string,
124
+ ): ts.SourceFile | undefined {
125
+ if (program === undefined) return undefined
126
+ const direct = program.getSourceFile(fileName)
127
+ if (direct !== undefined) return direct
128
+
129
+ // `getSourceFileByPath` is the Program's own canonical lookup. It is deliberately attempted before
130
+ // the filesystem scans below because an editor may provide a virtual source overlay that has no
131
+ // stat/realpath identity at all.
132
+ const programPath = typeScriptProgramPath(tsm, program, fileName)
133
+ if (programPath !== undefined) {
134
+ const byTypeScriptPath = program.getSourceFileByPath(programPath)
135
+ if (byTypeScriptPath !== undefined) return byTypeScriptPath
136
+ }
137
+
138
+ const wanted = new Set(canonicalProgramPaths(tsm, fileName))
139
+ const byPath = program
140
+ .getSourceFiles()
141
+ .find((source) => canonicalProgramPaths(tsm, source.fileName).some((path) => wanted.has(path)))
142
+ if (byPath !== undefined) return byPath
143
+
144
+ const wantedIdentity = filesystemIdentity(fileName)
145
+ if (wantedIdentity === undefined) return undefined
146
+ return program
147
+ .getSourceFiles()
148
+ .find((source) => filesystemIdentity(source.fileName) === wantedIdentity)
149
+ }
150
+
151
+ /** Parse the editor's current snapshot when the Program has canonicalized the on-disk path under a
152
+ * different spelling. This keeps route navigation correct for unsaved edits too; reading from disk
153
+ * here would make the plugin jump based on stale source while an editor buffer is open. */
154
+ function hostSourceFile(
155
+ tsm: typeof ts,
156
+ host: ts.LanguageServiceHost | undefined,
157
+ program: ts.Program | undefined,
158
+ fileName: string,
159
+ ): ts.SourceFile | undefined {
160
+ if (host === undefined) return undefined
161
+ const snapshot = host.getScriptSnapshot(fileName)
162
+ if (snapshot === undefined) return undefined
163
+ const text = snapshot.getText(0, snapshot.getLength())
164
+ const options = program?.getCompilerOptions()
165
+ const getScriptKindFromFileName = (
166
+ tsm as unknown as {
167
+ getScriptKindFromFileName?: (fileName: string) => ts.ScriptKind
168
+ }
169
+ ).getScriptKindFromFileName
170
+ return tsm.createSourceFile(
171
+ fileName,
172
+ text,
173
+ options?.target ?? tsm.ScriptTarget.Latest,
174
+ true,
175
+ host.getScriptKind?.(fileName) ??
176
+ (typeof getScriptKindFromFileName === "function"
177
+ ? getScriptKindFromFileName(fileName)
178
+ : tsm.ScriptKind.TSX),
179
+ )
180
+ }
181
+
182
+ /** Last-resort source overlay for embedders that omit `languageServiceHost` from PluginCreateInfo. */
183
+ function diskSourceFile(
184
+ tsm: typeof ts,
185
+ program: ts.Program | undefined,
186
+ fileName: string,
187
+ ): ts.SourceFile | undefined {
188
+ let text: string
189
+ try {
190
+ text = readFileSync(fileName, "utf8")
191
+ } catch {
192
+ return undefined
193
+ }
194
+ const getScriptKindFromFileName = (
195
+ tsm as unknown as {
196
+ getScriptKindFromFileName?: (fileName: string) => ts.ScriptKind
197
+ }
198
+ ).getScriptKindFromFileName
199
+ return tsm.createSourceFile(
200
+ fileName,
201
+ text,
202
+ program?.getCompilerOptions().target ?? tsm.ScriptTarget.Latest,
203
+ true,
204
+ typeof getScriptKindFromFileName === "function"
205
+ ? getScriptKindFromFileName(fileName)
206
+ : tsm.ScriptKind.TSX,
207
+ )
208
+ }
209
+
36
210
  /** The innermost AST node whose span contains `position`. */
37
211
  function innermostNodeAt(
38
212
  tsm: typeof ts,
@@ -70,10 +244,15 @@ export function findRoutePathLiteral(
70
244
  function routeDefinitionAt(
71
245
  tsm: typeof ts,
72
246
  ls: ts.LanguageService,
247
+ host: ts.LanguageServiceHost | undefined,
73
248
  fileName: string,
74
249
  position: number,
75
250
  ): ts.DefinitionInfoAndBoundSpan | undefined {
76
- const source = ls.getProgram()?.getSourceFile(fileName)
251
+ const program = ls.getProgram()
252
+ const source =
253
+ programSourceFile(tsm, program, fileName) ??
254
+ hostSourceFile(tsm, host, program, fileName) ??
255
+ diskSourceFile(tsm, program, fileName)
77
256
  if (source === undefined) return undefined
78
257
  const literal = findRoutePathLiteral(tsm, source, position)
79
258
  if (literal === undefined) return undefined
@@ -117,12 +296,34 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule {
117
296
  const member = ls[key]
118
297
  writable[key] =
119
298
  typeof member === "function"
120
- ? (member as (...args: unknown[]) => unknown).bind(ls)
299
+ ? (...args: unknown[]) => {
300
+ // TypeScript's public methods generally take the source file name first. Map an
301
+ // editor's short/long Windows spelling to the Program's own spelling before delegating;
302
+ // this preserves the real service (and its diagnostics) instead of swallowing a path
303
+ // mismatch as an empty result.
304
+ const fileName = args[0]
305
+ const source =
306
+ typeof fileName === "string"
307
+ ? programSourceFile(tsm, ls.getProgram(), fileName)
308
+ : undefined
309
+ return (member as (...args: unknown[]) => unknown).apply(ls, [
310
+ ...(source === undefined ? args : [source.fileName, ...args.slice(1)]),
311
+ ])
312
+ }
121
313
  : member
122
314
  }
123
315
  proxy.getDefinitionAndBoundSpan = (fileName, position) =>
124
- routeDefinitionAt(tsm, ls, fileName, position) ??
125
- ls.getDefinitionAndBoundSpan(fileName, position)
316
+ routeDefinitionAt(tsm, ls, info.languageServiceHost, fileName, position) ??
317
+ (() => {
318
+ // TypeScript throws when handed an editor spelling that is not the Program's canonical file
319
+ // name (notably an 8.3 Windows alias). Returning no definition is safer than turning a normal
320
+ // editor request into an exception; every other language-service method remains delegated
321
+ // unchanged through the proxy above.
322
+ const source = programSourceFile(tsm, ls.getProgram(), fileName)
323
+ return source === undefined
324
+ ? undefined
325
+ : ls.getDefinitionAndBoundSpan(source.fileName, position)
326
+ })()
126
327
  return proxy
127
328
  },
128
329
  }