@tsrx/typescript-plugin 0.3.25

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.
@@ -0,0 +1,920 @@
1
+ /** @import { CodeMapping } from '@tsrx/ripple' */
2
+ /** @import {TSRXCompileError, VolarMappingsResult} from '@tsrx/ripple' */
3
+
4
+ /** @typedef {{ compile_to_volar_mappings(source: string, filename: string, options?: { loose?: boolean }): VolarMappingsResult }} TSRXCompilerModule */
5
+
6
+ /** @typedef {Map<string, CodeMapping>} CachedMappings */
7
+ /** @typedef {import('typescript').CompilerOptions} CompilerOptions */
8
+ /** @typedef {import('@volar/language-core').IScriptSnapshot} IScriptSnapshot */
9
+ /** @typedef {import('@volar/language-core').VirtualCode} VirtualCode */
10
+ /** @typedef {string | { fsPath: string }} ScriptId */
11
+ // Side-effect import: augments @volar/language-core's LanguagePlugin with the `typescript` field.
12
+ /** @typedef {typeof import('@volar/typescript')} _VolarTypeScriptAugmentation */
13
+ /** @typedef {import('@volar/language-core').LanguagePlugin<ScriptId, VirtualCode>} RippleLanguagePlugin */
14
+
15
+ /** @typedef {InstanceType<typeof import('./language.js')["TSRXVirtualCode"]>} TSRXVirtualCodeInstance */
16
+
17
+ import ts from 'typescript';
18
+ import { forEachEmbeddedCode } from '@volar/language-core';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { createRequire } from 'module';
22
+ import { fileURLToPath } from 'url';
23
+ import { createLogging, DEBUG } from './utils.js';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const root_dirname = path.dirname(fileURLToPath(import.meta.url));
27
+
28
+ const { log, logWarning, logError } = createLogging('[Ripple Language]');
29
+ export const RIPPLE_EXTENSIONS = ['.tsrx'];
30
+ /** @typedef {[string, string[], string[], string[]]} CompilerCandidate */
31
+ /** @type {CompilerCandidate[]} */
32
+ export const COMPILER_CANDIDATES = [
33
+ [
34
+ '@tsrx/ripple',
35
+ ['node_modules', '@tsrx', 'ripple'],
36
+ ['.tsrx'],
37
+ ['@tsrx/ripple', 'ripple', '@ripple-ts/vite-plugin', '@ripple-ts/compat-react'],
38
+ ],
39
+ [
40
+ '@tsrx/react',
41
+ ['node_modules', '@tsrx', 'react'],
42
+ ['.tsrx'],
43
+ ['@tsrx/react', '@tsrx/vite-plugin-react'],
44
+ ],
45
+ [
46
+ '@tsrx/solid',
47
+ ['node_modules', '@tsrx', 'solid'],
48
+ ['.tsrx'],
49
+ ['@tsrx/solid', '@tsrx/vite-plugin-solid'],
50
+ ],
51
+ ];
52
+
53
+ /**
54
+ * @param {string} file_name
55
+ * @returns {boolean}
56
+ */
57
+ export function is_ripple_file(file_name) {
58
+ return RIPPLE_EXTENSIONS.some((extension) => file_name.endsWith(extension));
59
+ }
60
+
61
+ /**
62
+ * @returns {RippleLanguagePlugin}
63
+ */
64
+ export function getRippleLanguagePlugin() {
65
+ log('Creating Ripple language plugin...');
66
+
67
+ return {
68
+ getLanguageId(fileNameOrUri) {
69
+ const file_name =
70
+ typeof fileNameOrUri === 'string'
71
+ ? fileNameOrUri
72
+ : fileNameOrUri.fsPath.replace(/\\/g, '/');
73
+ if (is_ripple_file(file_name)) {
74
+ log('Identified Ripple file:', file_name);
75
+ return 'ripple';
76
+ }
77
+ },
78
+ createVirtualCode(fileNameOrUri, languageId, snapshot) {
79
+ if (languageId === 'ripple') {
80
+ const file_name = normalizeFileNameOrUri(fileNameOrUri);
81
+ const ripple = get_tsrx_compiler(file_name);
82
+ if (!ripple) {
83
+ logError(`Ripple compiler not found for file: ${file_name}`);
84
+ return undefined;
85
+ }
86
+ log('Creating virtual code for:', file_name);
87
+ try {
88
+ return new TSRXVirtualCode(file_name, snapshot, ripple);
89
+ } catch (err) {
90
+ logError('Failed to create virtual code for:', file_name, ':', err);
91
+ throw err;
92
+ }
93
+ }
94
+ return undefined;
95
+ },
96
+ updateVirtualCode(fileNameOrUri, virtualCode, snapshot) {
97
+ if (virtualCode instanceof TSRXVirtualCode) {
98
+ log('Updating existing virtual code for:', virtualCode.fileName);
99
+ virtualCode.update(snapshot);
100
+ return virtualCode;
101
+ }
102
+ return undefined;
103
+ },
104
+
105
+ typescript: {
106
+ extraFileExtensions: RIPPLE_EXTENSIONS.map((extension) => ({
107
+ extension: extension.slice(1),
108
+ isMixedContent: false,
109
+ scriptKind: 7,
110
+ })),
111
+ /**
112
+ * @param {VirtualCode} ripple_code
113
+ */
114
+ getServiceScript(ripple_code) {
115
+ for (const code of forEachEmbeddedCode(ripple_code)) {
116
+ if (code.languageId === 'ripple') {
117
+ return {
118
+ code,
119
+ extension: '.tsx',
120
+ scriptKind: 4,
121
+ };
122
+ }
123
+ }
124
+ return undefined;
125
+ },
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @implements {VirtualCode}
132
+ */
133
+ export class TSRXVirtualCode {
134
+ /** @type {string} */
135
+ id = 'root';
136
+ /** @type {string} */
137
+ languageId = 'ripple';
138
+ /** @type {unknown[]} */
139
+ codegenStacks = [];
140
+ /** @type {TSRXCompilerModule} */
141
+ tsrx;
142
+ /** @type {string} */
143
+ generatedCode = '';
144
+ /** @type {VirtualCode['embeddedCodes']} */
145
+ embeddedCodes = [];
146
+ /** @type {CodeMapping[]} */
147
+ mappings = [];
148
+ /** @type {TSRXCompileError[]} */
149
+ fatalErrors = [];
150
+ /** @type {TSRXCompileError[]} */
151
+ usageErrors = [];
152
+ /** @type {IScriptSnapshot} */
153
+ snapshot;
154
+ /** @type {IScriptSnapshot} */
155
+ sourceSnapshot;
156
+ /** @type {string} */
157
+ originalCode = '';
158
+ /** @type {unknown[]} */
159
+ diagnostics = [];
160
+ /** @type {CachedMappings | null} */
161
+ #mappingGenToSource = null;
162
+ /** @type {CachedMappings | null} */
163
+ #mappingSourceToGen = null;
164
+
165
+ /**
166
+ * @param {string} file_name
167
+ * @param {IScriptSnapshot} snapshot
168
+ * @param {TSRXCompilerModule} tsrx
169
+ */
170
+ constructor(file_name, snapshot, tsrx) {
171
+ log('Initializing TSRXVirtualCode for:', file_name);
172
+
173
+ this.fileName = file_name;
174
+ this.tsrx = tsrx;
175
+ this.snapshot = snapshot;
176
+ this.sourceSnapshot = snapshot;
177
+ this.originalCode = snapshot.getText(0, snapshot.getLength());
178
+
179
+ // Validate ripple compiler
180
+ if (!tsrx || typeof tsrx.compile_to_volar_mappings !== 'function') {
181
+ logError('Invalid ripple compiler - missing compile_to_volar_mappings method');
182
+ throw new Error('Invalid ripple compiler');
183
+ }
184
+
185
+ this.update(snapshot);
186
+ }
187
+
188
+ /**
189
+ * @param {IScriptSnapshot} snapshot
190
+ * @returns {void}
191
+ */
192
+ update(snapshot) {
193
+ log('Updating virtual code for:', this.fileName);
194
+
195
+ const newCode = snapshot.getText(0, snapshot.getLength());
196
+ const changeRange = snapshot.getChangeRange(this.sourceSnapshot);
197
+ this.sourceSnapshot = snapshot;
198
+
199
+ // Only clear mapping index - don't update snapshot/originalCode yet
200
+ this.#mappingGenToSource = null;
201
+ this.#mappingSourceToGen = null;
202
+
203
+ this.fatalErrors = [];
204
+ this.usageErrors = [];
205
+
206
+ /** @type {VolarMappingsResult | undefined} */
207
+ let transpiled;
208
+
209
+ // Check if a single "." was typed using changeRange
210
+ let isDotTyped = false;
211
+ let dotPosition = -1;
212
+
213
+ log('changeRange:', JSON.stringify(changeRange));
214
+
215
+ if (changeRange) {
216
+ const changeStart = changeRange.span.start;
217
+ const changeEnd = changeStart + changeRange.span.length;
218
+ const newEnd = changeStart + changeRange.newLength;
219
+
220
+ // Get the old text (what was replaced) from originalCode
221
+ const oldText = this.originalCode.substring(changeStart, changeEnd);
222
+ // Get the new text (what replaced it) from newCode
223
+ const newText = newCode.substring(changeStart, newEnd);
224
+
225
+ log('Change details:');
226
+ log(' Position:', changeStart, '-', changeEnd, '(length:', changeRange.span.length, ')');
227
+ log(' Old text:', JSON.stringify(oldText));
228
+ log(' New text:', JSON.stringify(newText), '(length:', changeRange.newLength, ')');
229
+
230
+ // Check if a dot was added at the end of the new text
231
+ if (newText.endsWith('.')) {
232
+ // The dot is at position newEnd - 1
233
+ // We need to check the character BEFORE the dot (inside the new text)
234
+ const charBeforeDot = newEnd > 1 ? newCode[newEnd - 2] : '';
235
+ log(' Char before dot:', JSON.stringify(charBeforeDot));
236
+
237
+ if (/[$#_\u200C\u200D\p{ID_Continue}\)\]\}]/u.test(charBeforeDot)) {
238
+ isDotTyped = true;
239
+ dotPosition = newEnd - 1; // Position of the dot
240
+ log('ChangeRange detected dot typed at position', dotPosition);
241
+ }
242
+ }
243
+ }
244
+
245
+ try {
246
+ // If user typed a ".", compile without it and then stitch it back into
247
+ // the generated output so completions can still resolve.
248
+ if (isDotTyped && dotPosition >= 0) {
249
+ const codeWithoutDot =
250
+ newCode.substring(0, dotPosition) + newCode.substring(dotPosition + 1);
251
+
252
+ log('Compiling without typed dot at position', dotPosition);
253
+ transpiled = this.tsrx.compile_to_volar_mappings(codeWithoutDot, this.fileName, {
254
+ loose: true,
255
+ });
256
+ log('Compilation without dot successful');
257
+
258
+ if (transpiled && transpiled.code && transpiled.mappings.length > 0) {
259
+ const insertedDotPosition = restore_typed_dot_in_transpiled_code(transpiled, dotPosition);
260
+
261
+ if (insertedDotPosition === null) {
262
+ logWarning('Failed to restore typed dot into transpiled output');
263
+ } else {
264
+ log('Inserted typed dot at generated position', insertedDotPosition);
265
+ }
266
+ }
267
+ } else {
268
+ // Normal compilation
269
+ log('Compiling Ripple code...');
270
+ transpiled = this.tsrx.compile_to_volar_mappings(newCode, this.fileName, {
271
+ loose: true,
272
+ });
273
+ log('Compilation successful, generated code length:', transpiled?.code?.length || 0);
274
+ }
275
+ } catch (e) {
276
+ const error = /** @type {TSRXCompileError} */ (e);
277
+ logError('Ripple compilation failed for', this.fileName, ':', error);
278
+ error.type = 'fatal';
279
+ this.fatalErrors.push(error);
280
+ }
281
+
282
+ if (transpiled && transpiled.code) {
283
+ // Successful compilation - update everything
284
+ this.originalCode = newCode;
285
+ this.generatedCode = transpiled.code;
286
+ this.mappings = transpiled.mappings ?? [];
287
+ this.usageErrors = transpiled.errors;
288
+
289
+ const cssMappings = transpiled.cssMappings;
290
+ if (cssMappings.length > 0) {
291
+ log('Creating', cssMappings.length, 'CSS embedded codes');
292
+
293
+ this.embeddedCodes = cssMappings.map((mapping, index) => {
294
+ const cssContent = /** @type {string} */ (mapping.data?.customData?.content);
295
+ log(
296
+ `CSS region ${index}: \
297
+ offset ${mapping.sourceOffsets[0]}-${mapping.sourceOffsets[0] + mapping.lengths[0]}, \
298
+ length ${mapping.lengths[0]}`,
299
+ );
300
+
301
+ return {
302
+ id: /** @type {string} */ (mapping.data?.customData?.embeddedId),
303
+ languageId: 'css',
304
+ snapshot: {
305
+ getText: (/** @type {number} */ start, /** @type {number} */ end) =>
306
+ cssContent.substring(start, end),
307
+ getLength: () => mapping.lengths[0],
308
+ getChangeRange: () => undefined,
309
+ },
310
+ mappings: [mapping],
311
+ embeddedCodes: [],
312
+ };
313
+ });
314
+ } else {
315
+ this.embeddedCodes = [];
316
+ }
317
+
318
+ if (DEBUG) {
319
+ log('CSS embedded codes:', (this.embeddedCodes || []).length);
320
+ log('Using transpiled code, mapping count:', this.mappings.length);
321
+ log('Original code length:', newCode.length);
322
+ log('Generated code length:', this.generatedCode.length);
323
+ log('Last 100 chars of original:', JSON.stringify(newCode.slice(-100)));
324
+ log('Last 200 chars of generated:', JSON.stringify(this.generatedCode.slice(-200)));
325
+ log('Last few mappings:');
326
+ const startIdx = Math.max(0, this.mappings.length - 5);
327
+ for (let i = startIdx; i < this.mappings.length; i++) {
328
+ const m = this.mappings[i];
329
+ log(
330
+ ` Mapping ${i}: source[${m.sourceOffsets[0]}:${m.sourceOffsets[0] + m.lengths[0]}] -> gen[${m.generatedOffsets[0]}:${m.generatedOffsets[0] + m.lengths[0]}], len=${m.lengths[0]}, completion=${m.data?.completion}`,
331
+ );
332
+ }
333
+ }
334
+
335
+ this.snapshot = /** @type {IScriptSnapshot} */ ({
336
+ getText: (start, end) => this.generatedCode.substring(start, end),
337
+ getLength: () => this.generatedCode.length,
338
+ getChangeRange: () => undefined,
339
+ });
340
+ } else {
341
+ // When compilation fails, show where it failed and disable all
342
+ // TypeScript diagnostics until the compilation error is fixed
343
+ log('Compilation failed, only display where the compilation error occurred.');
344
+
345
+ this.originalCode = newCode;
346
+ this.generatedCode = newCode;
347
+
348
+ // Create 1:1 mappings for the entire content
349
+ this.mappings = [
350
+ {
351
+ sourceOffsets: [0],
352
+ generatedOffsets: [0],
353
+ lengths: [newCode.length],
354
+ generatedLengths: [newCode.length],
355
+ data: {
356
+ verification: true, // disable TS since we're using source code as generated code
357
+ customData: {},
358
+ },
359
+ },
360
+ ];
361
+
362
+ // Extract CSS from <style>...</style> tags for embedded codes
363
+ this.embeddedCodes = extractCssFromSource(newCode);
364
+
365
+ this.snapshot = /** @type {IScriptSnapshot} */ ({
366
+ getText: (start, end) => this.generatedCode.substring(start, end),
367
+ getLength: () => this.generatedCode.length,
368
+ getChangeRange: () => undefined,
369
+ });
370
+ }
371
+ }
372
+
373
+ #buildMappingCache() {
374
+ if (this.#mappingGenToSource || this.#mappingSourceToGen) {
375
+ return;
376
+ }
377
+
378
+ this.#mappingGenToSource = new Map();
379
+ this.#mappingSourceToGen = new Map();
380
+
381
+ var mapping, genStart, genLength, genEnd, genKey;
382
+ var sourceStart, sourceLength, sourceEnd, sourceKey;
383
+ for (var i = 0; i < this.mappings.length; i++) {
384
+ mapping = this.mappings[i];
385
+
386
+ genStart = mapping.generatedOffsets[0];
387
+ genLength = mapping.generatedLengths[0];
388
+ genEnd = genStart + genLength;
389
+ genKey = `${genStart}-${genEnd}`;
390
+ this.#mappingGenToSource.set(genKey, mapping);
391
+
392
+ sourceStart = mapping.sourceOffsets[0];
393
+ sourceLength = mapping.lengths[0];
394
+ sourceEnd = sourceStart + sourceLength;
395
+ sourceKey = `${sourceStart}-${sourceEnd}`;
396
+ this.#mappingSourceToGen.set(sourceKey, mapping);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Find mapping by generated range
402
+ * @param {number} start - The start offset of the range
403
+ * @param {number} end - The end offset of the range
404
+ * @returns {CodeMapping | null} The mapping for this range, or null if not found
405
+ */
406
+ findMappingByGeneratedRange(start, end) {
407
+ this.#buildMappingCache();
408
+ return /** @type {CachedMappings} */ (this.#mappingGenToSource).get(`${start}-${end}`) ?? null;
409
+ }
410
+
411
+ /**
412
+ * Find mapping by source range
413
+ * @param {number} start - The start offset of the range
414
+ * @param {number} end - The end offset of the range
415
+ * @returns {CodeMapping | null} The mapping for this range, or null if not found
416
+ */
417
+ findMappingBySourceRange(start, end) {
418
+ this.#buildMappingCache();
419
+ return /** @type {CachedMappings} */ (this.#mappingSourceToGen).get(`${start}-${end}`) ?? null;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Extract CSS content from <style>...</style> tags in source code
425
+ * @param {string} code - The source code to extract CSS from
426
+ * @returns {VirtualCode[]} Array of embedded CSS virtual codes
427
+ */
428
+ function extractCssFromSource(code) {
429
+ /** @type {VirtualCode[]} */
430
+ const embeddedCodes = [];
431
+ const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
432
+ let match;
433
+ let index = 0;
434
+
435
+ while ((match = styleRegex.exec(code)) !== null) {
436
+ const fullMatch = match[0];
437
+ const cssContent = match[1];
438
+ const styleTagStart = match.index;
439
+ const openTagEnd = fullMatch.indexOf('>') + 1;
440
+ const cssStart = styleTagStart + openTagEnd;
441
+ const cssLength = cssContent.length;
442
+
443
+ log(`Extracted CSS region ${index}: offset ${cssStart}, length ${cssLength}`);
444
+
445
+ /** @type {CodeMapping} */
446
+ const mapping = {
447
+ sourceOffsets: [cssStart],
448
+ generatedOffsets: [0],
449
+ lengths: [cssLength],
450
+ generatedLengths: [cssLength],
451
+ data: {
452
+ verification: true,
453
+ completion: true,
454
+ semantic: true,
455
+ navigation: true,
456
+ structure: true,
457
+ format: false,
458
+ customData: {
459
+ content: cssContent,
460
+ embeddedId: `style_${index}`,
461
+ },
462
+ },
463
+ };
464
+
465
+ embeddedCodes.push({
466
+ id: `style_${index}`,
467
+ languageId: 'css',
468
+ snapshot: {
469
+ getText: (start, end) => cssContent.substring(start, end),
470
+ getLength: () => cssLength,
471
+ getChangeRange: () => undefined,
472
+ },
473
+ mappings: [mapping],
474
+ embeddedCodes: [],
475
+ });
476
+
477
+ index++;
478
+ }
479
+
480
+ if (embeddedCodes.length > 0) {
481
+ log(`Extracted ${embeddedCodes.length} CSS embedded codes from style tags`);
482
+ }
483
+
484
+ return embeddedCodes;
485
+ }
486
+
487
+ /**
488
+ * Insert a typed dot back into the transpiled code and update mappings so the
489
+ * source and generated offsets stay aligned for completion requests.
490
+ * @param {VolarMappingsResult} transpiled
491
+ * @param {number} dotPosition
492
+ * @returns {number | null}
493
+ */
494
+ function restore_typed_dot_in_transpiled_code(transpiled, dotPosition) {
495
+ let dot_mapping = null;
496
+
497
+ for (const mapping of transpiled.mappings) {
498
+ const source_end = mapping.sourceOffsets[0] + mapping.lengths[0];
499
+ if (source_end === dotPosition) {
500
+ dot_mapping = mapping;
501
+ break;
502
+ }
503
+ }
504
+
505
+ if (!dot_mapping) {
506
+ return null;
507
+ }
508
+
509
+ const generated_length = dot_mapping.generatedLengths[0];
510
+ const insertedDotPosition = dot_mapping.generatedOffsets[0] + generated_length;
511
+
512
+ transpiled.code =
513
+ transpiled.code.substring(0, insertedDotPosition) +
514
+ '.' +
515
+ transpiled.code.substring(insertedDotPosition);
516
+
517
+ // Create a separate 1:1 mapping for the dot character instead of extending
518
+ // the existing mapping. When source and generated lengths differ (e.g.
519
+ // #ripple → _$__u0023_ripple), Volar's translateOffset uses
520
+ // Math.min(relativePos, toLength) which would map the cursor after the dot
521
+ // to the middle of the generated identifier instead of after it.
522
+
523
+ /** @type {CodeMapping} */
524
+ const new_dot_mapping = {
525
+ sourceOffsets: [dotPosition],
526
+ generatedOffsets: [insertedDotPosition],
527
+ lengths: [1],
528
+ generatedLengths: [1],
529
+ data: { ...dot_mapping.data },
530
+ };
531
+
532
+ // Find the index to insert after dot_mapping
533
+ const dot_mapping_index = transpiled.mappings.indexOf(dot_mapping);
534
+ transpiled.mappings.splice(dot_mapping_index + 1, 0, new_dot_mapping);
535
+
536
+ for (const mapping of transpiled.mappings) {
537
+ if (
538
+ mapping !== dot_mapping &&
539
+ mapping !== new_dot_mapping &&
540
+ mapping.generatedOffsets[0] >= insertedDotPosition
541
+ ) {
542
+ mapping.generatedOffsets[0] += 1;
543
+ }
544
+ if (
545
+ mapping !== dot_mapping &&
546
+ mapping !== new_dot_mapping &&
547
+ mapping.sourceOffsets[0] >= dotPosition
548
+ ) {
549
+ mapping.sourceOffsets[0] += 1;
550
+ }
551
+ }
552
+
553
+ return insertedDotPosition;
554
+ }
555
+
556
+ /**
557
+ * @template T
558
+ * @param {{ options?: CompilerOptions } & T} config
559
+ * @returns {{ options: CompilerOptions } & T}
560
+ */
561
+ export const resolveConfig = (config) => {
562
+ const baseOptions = config.options ?? /** @type {CompilerOptions} */ ({});
563
+ /** @type {CompilerOptions} */
564
+ const options = { ...baseOptions };
565
+
566
+ // Default target: align with modern bundlers while staying configurable.
567
+ if (options.target === undefined) {
568
+ options.target = ts.ScriptTarget.ESNext;
569
+ }
570
+
571
+ /** @param {string} libName */
572
+ const normalizeLibName = (libName) => {
573
+ if (typeof libName !== 'string' || libName.length === 0) {
574
+ return undefined;
575
+ }
576
+ const trimmed = libName.trim();
577
+ if (trimmed.startsWith('lib.')) {
578
+ return trimmed.toLowerCase();
579
+ }
580
+ return `lib.${trimmed.toLowerCase().replace(/\s+/g, '').replace(/_/g, '.')}\.d.ts`;
581
+ };
582
+
583
+ const normalizedLibs = new Set(
584
+ (options.lib ?? []).map(normalizeLibName).filter((lib) => typeof lib === 'string'),
585
+ );
586
+
587
+ if (normalizedLibs.size === 0) {
588
+ const host = ts.createCompilerHost(options);
589
+ const defaultLibFileName = host.getDefaultLibFileName(options).toLowerCase();
590
+ normalizedLibs.add(defaultLibFileName);
591
+ normalizedLibs.add('lib.dom.d.ts');
592
+ normalizedLibs.add('lib.dom.iterable.d.ts');
593
+ }
594
+
595
+ options.lib = [...normalizedLibs];
596
+
597
+ // Default typeRoots: automatically discover @types like tsserver.
598
+ if (!options.types) {
599
+ const host = ts.createCompilerHost(options);
600
+ const typeRoots = ts.getEffectiveTypeRoots(options, host);
601
+ if (typeRoots && typeRoots.length > 0) {
602
+ options.typeRoots = typeRoots;
603
+ }
604
+ }
605
+
606
+ return {
607
+ ...config,
608
+ options,
609
+ };
610
+ };
611
+
612
+ /** @type {Map<string, string | null>} */
613
+ export const path2RipplePathMap = new Map();
614
+ /** @type {Map<string, string>} */
615
+ const pathToTypesCache = new Map();
616
+ /** @type {Map<string, RegExpMatchArray>} */
617
+ const typeNameMatchCache = new Map();
618
+ /** @type {Map<string, { name: string | null, dependencies: Set<string> } | null>} */
619
+ const pathToPackageManifestCache = new Map();
620
+
621
+ /**
622
+ * @param {ScriptId} fileNameOrUri
623
+ * @returns {string}
624
+ */
625
+ export function normalizeFileNameOrUri(fileNameOrUri) {
626
+ return typeof fileNameOrUri === 'string'
627
+ ? fileNameOrUri
628
+ : fileNameOrUri.fsPath.replace(/\\/g, '/');
629
+ }
630
+
631
+ /**
632
+ * @param {string} start_dir
633
+ * @param {(file_path: import('fs').PathLike) => boolean} [exists_sync]
634
+ * @returns {{ name: string | null, dependencies: Set<string> } | null}
635
+ */
636
+ function get_nearest_package_manifest(start_dir, exists_sync = fs.existsSync) {
637
+ let current_dir = start_dir;
638
+ /** @type {string[]} */
639
+ const visited_dirs = [];
640
+
641
+ while (current_dir) {
642
+ if (pathToPackageManifestCache.has(current_dir)) {
643
+ const cached_manifest = pathToPackageManifestCache.get(current_dir) ?? null;
644
+ for (const visited_dir of visited_dirs) {
645
+ pathToPackageManifestCache.set(visited_dir, cached_manifest);
646
+ }
647
+ return cached_manifest;
648
+ }
649
+
650
+ visited_dirs.push(current_dir);
651
+
652
+ const package_json_path = path.join(current_dir, 'package.json');
653
+ if (exists_sync(package_json_path)) {
654
+ try {
655
+ const package_json = JSON.parse(fs.readFileSync(package_json_path, 'utf8'));
656
+ const dependencies = new Set([
657
+ ...Object.keys(package_json.dependencies ?? {}),
658
+ ...Object.keys(package_json.devDependencies ?? {}),
659
+ ...Object.keys(package_json.peerDependencies ?? {}),
660
+ ...Object.keys(package_json.optionalDependencies ?? {}),
661
+ ]);
662
+ const package_manifest = {
663
+ name: typeof package_json.name === 'string' ? package_json.name : null,
664
+ dependencies,
665
+ };
666
+
667
+ for (const visited_dir of visited_dirs) {
668
+ pathToPackageManifestCache.set(visited_dir, package_manifest);
669
+ }
670
+
671
+ return package_manifest;
672
+ } catch {
673
+ for (const visited_dir of visited_dirs) {
674
+ pathToPackageManifestCache.set(visited_dir, null);
675
+ }
676
+ return null;
677
+ }
678
+ }
679
+
680
+ const parent_dir = path.dirname(current_dir);
681
+ if (parent_dir === current_dir) {
682
+ break;
683
+ }
684
+ current_dir = parent_dir;
685
+ }
686
+
687
+ for (const visited_dir of visited_dirs) {
688
+ pathToPackageManifestCache.set(visited_dir, null);
689
+ }
690
+
691
+ return null;
692
+ }
693
+
694
+ /**
695
+ * @param {{ name: string | null, dependencies: Set<string> } | null} package_manifest
696
+ * @param {string} compiler_name
697
+ * @param {string[]} package_hints
698
+ * @returns {boolean}
699
+ */
700
+ function package_manifest_matches_compiler(package_manifest, compiler_name, package_hints) {
701
+ if (!package_manifest) {
702
+ return false;
703
+ }
704
+
705
+ if (
706
+ package_manifest.name === compiler_name ||
707
+ package_hints.includes(package_manifest.name ?? '')
708
+ ) {
709
+ return true;
710
+ }
711
+
712
+ if (package_manifest.dependencies.has(compiler_name)) {
713
+ return true;
714
+ }
715
+
716
+ for (const package_hint of package_hints) {
717
+ if (package_manifest.dependencies.has(package_hint)) {
718
+ return true;
719
+ }
720
+ }
721
+
722
+ return false;
723
+ }
724
+
725
+ /**
726
+ * @param {string} normalized_file_name
727
+ * @returns {TSRXCompilerModule | undefined}
728
+ */
729
+ function get_tsrx_compiler(normalized_file_name) {
730
+ const compiler_path = get_compiler_entry_for_file(normalized_file_name);
731
+ if (compiler_path) {
732
+ return require(compiler_path);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * @param {string} normalized_file_name
738
+ * @param {(file_path: import('fs').PathLike) => boolean} [exists_sync]
739
+ * @param {Map<string, string | null>} [compiler_path_map]
740
+ * @returns {string | undefined}
741
+ */
742
+ export function find_workspace_compiler_entry_for_file(
743
+ normalized_file_name,
744
+ exists_sync = fs.existsSync,
745
+ compiler_path_map = path2RipplePathMap,
746
+ ) {
747
+ const parts = normalized_file_name.split('/');
748
+ const ext = path.extname(normalized_file_name);
749
+
750
+ for (let i = parts.length - 2; i >= 0; i--) {
751
+ const dir = parts.slice(0, i + 1).join('/');
752
+ const cache_key = dir + '\0' + ext;
753
+
754
+ if (!compiler_path_map.has(cache_key)) {
755
+ /** @type {Array<[string, string, string[]]>} */
756
+ const available_candidates = [];
757
+ for (const [
758
+ compiler_name,
759
+ compiler_dir_parts,
760
+ supported_extensions,
761
+ package_hints,
762
+ ] of COMPILER_CANDIDATES) {
763
+ if (!supported_extensions.includes(ext)) {
764
+ continue;
765
+ }
766
+ const full_path = [dir, ...compiler_dir_parts, 'src', 'index.js'].join('/');
767
+ if (exists_sync(full_path)) {
768
+ available_candidates.push([compiler_name, full_path, package_hints]);
769
+ }
770
+ }
771
+
772
+ let found_path = null;
773
+ if (available_candidates.length > 0) {
774
+ const package_manifest = get_nearest_package_manifest(dir, exists_sync);
775
+ const preferred_candidate = available_candidates.find(([compiler_name, , package_hints]) =>
776
+ package_manifest_matches_compiler(package_manifest, compiler_name, package_hints),
777
+ );
778
+ found_path = preferred_candidate?.[1] ?? available_candidates[0][1];
779
+ log('Found tsrx compiler at:', found_path, 'for extension:', ext);
780
+ }
781
+
782
+ compiler_path_map.set(cache_key, found_path);
783
+ }
784
+
785
+ const compiler_path = compiler_path_map.get(cache_key);
786
+ if (compiler_path) {
787
+ return compiler_path;
788
+ }
789
+ }
790
+ }
791
+
792
+ /**
793
+ * @param {string} normalized_file_name
794
+ * @returns {string | undefined}
795
+ */
796
+ export function get_compiler_entry_for_file(normalized_file_name) {
797
+ const ext = path.extname(normalized_file_name);
798
+ const package_manifest = get_nearest_package_manifest(path.dirname(normalized_file_name));
799
+
800
+ const workspace_compiler_path = find_workspace_compiler_entry_for_file(normalized_file_name);
801
+ if (workspace_compiler_path) {
802
+ return workspace_compiler_path;
803
+ }
804
+
805
+ const warn_message = `No supported tsrx compiler found in workspace for ${normalized_file_name}.`;
806
+
807
+ // Fallback: look for a packaged compiler.
808
+ let current_dir = root_dirname;
809
+
810
+ while (current_dir) {
811
+ /** @type {Array<[string, string, string[]]>} */
812
+ const available_candidates = [];
813
+ for (const [
814
+ compiler_name,
815
+ compiler_dir_parts,
816
+ supported_extensions,
817
+ package_hints,
818
+ ] of COMPILER_CANDIDATES) {
819
+ if (!supported_extensions.includes(ext)) {
820
+ continue;
821
+ }
822
+ const full_path = path.join(current_dir, ...compiler_dir_parts);
823
+ const entry_path = path.join(full_path, 'src', 'index.js');
824
+ if (fs.existsSync(entry_path)) {
825
+ available_candidates.push([compiler_name, entry_path, package_hints]);
826
+ }
827
+ }
828
+
829
+ if (available_candidates.length > 0) {
830
+ const preferred_candidate = available_candidates.find(([compiler_name, , package_hints]) =>
831
+ package_manifest_matches_compiler(package_manifest, compiler_name, package_hints),
832
+ );
833
+ const entry_path = preferred_candidate?.[1] ?? available_candidates[0][1];
834
+ logWarning(`${warn_message} Using packaged version at ${entry_path}`);
835
+ return entry_path;
836
+ }
837
+
838
+ const parent_dir = path.dirname(current_dir);
839
+ if (parent_dir === current_dir) {
840
+ break;
841
+ }
842
+ current_dir = parent_dir;
843
+ }
844
+
845
+ return undefined;
846
+ }
847
+
848
+ /**
849
+ * @param {string} typesFilePath
850
+ * @returns {string | undefined}
851
+ */
852
+ export function getCachedTypeDefinitionFile(typesFilePath) {
853
+ const cached = pathToTypesCache.get(typesFilePath);
854
+ if (cached) {
855
+ return cached;
856
+ }
857
+
858
+ if (!fs.existsSync(typesFilePath)) {
859
+ logWarning(`Types file does not exist at path: ${typesFilePath}`);
860
+ return;
861
+ }
862
+
863
+ log(`Found ripple types at: ${typesFilePath}`);
864
+
865
+ // Read the file to find the class definition offset
866
+ const fileContent = fs.readFileSync(typesFilePath, 'utf8');
867
+
868
+ if (!fileContent) {
869
+ logWarning(`Failed to read content of types file at: ${typesFilePath}`);
870
+ return;
871
+ }
872
+
873
+ pathToTypesCache.set(typesFilePath, fileContent);
874
+ return fileContent;
875
+ }
876
+
877
+ /**
878
+ * @param {string} typeName
879
+ * @param {string} text
880
+ * @returns {RegExpMatchArray | undefined}
881
+ */
882
+ export function getCachedTypeMatches(typeName, text) {
883
+ const cached = typeNameMatchCache.get(typeName);
884
+ if (cached) {
885
+ return cached;
886
+ }
887
+
888
+ const searchPattern = new RegExp(
889
+ `(?:export\\s+(?:declare\\s+)?|declare\\s+)(class|function)\\s+${typeName}`,
890
+ );
891
+
892
+ const match = text.match(searchPattern);
893
+
894
+ if (match && match.index !== undefined) {
895
+ typeNameMatchCache.set(typeName, match);
896
+ return match;
897
+ }
898
+
899
+ return;
900
+ }
901
+
902
+ /**
903
+ * @param {string} normalized_file_name
904
+ * @returns {string | undefined}
905
+ */
906
+ export function get_compiler_dir_for_file(normalized_file_name) {
907
+ const entry = get_compiler_entry_for_file(normalized_file_name);
908
+ if (entry) {
909
+ // Walk up from .../src/index.js to the package root
910
+ return path.dirname(path.dirname(entry));
911
+ }
912
+ }
913
+
914
+ export { get_compiler_dir_for_file as getRippleDirForFile };
915
+
916
+ /** Reset module-level state used in tests. */
917
+ export function _reset_for_test() {
918
+ path2RipplePathMap.clear();
919
+ pathToPackageManifestCache.clear();
920
+ }