@ripple-ts/language-server 0.2.187 → 0.2.189

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ripple-ts/language-server",
3
- "version": "0.2.187",
3
+ "version": "0.2.189",
4
4
  "description": "Language Server Protocol implementation for Ripple",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -19,10 +19,10 @@
19
19
  "volar-service-typescript": "0.0.65",
20
20
  "vscode-languageserver-textdocument": "^1.0.12",
21
21
  "vscode-uri": "^3.1.0",
22
- "@ripple-ts/typescript-plugin": "0.2.187"
22
+ "@ripple-ts/typescript-plugin": "0.2.189"
23
23
  },
24
24
  "devDependencies": {
25
- "ripple": "0.2.187"
25
+ "ripple": "0.2.189"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "typescript": "^5.9.2"
@@ -66,6 +66,11 @@ function createAutoInsertPlugin() {
66
66
 
67
67
  const [virtualCode] = getVirtualCode(document, context);
68
68
 
69
+ if (virtualCode.languageId !== 'ripple') {
70
+ log(`Skipping auto-insert processing in the '${virtualCode.languageId}' context`);
71
+ return null;
72
+ }
73
+
69
74
  // Map position back to source
70
75
  const offset = document.offsetAt(position);
71
76
  const mapping = virtualCode.findMappingByGeneratedRange(lastChange.rangeOffset, offset);
@@ -1,17 +1,209 @@
1
- /**
2
- * @import {LanguageServicePlugin} from '@volar/language-server'
3
- */
1
+ /** @import { LanguageServicePlugin, TextEdit, CompletionItem } from '@volar/language-server'; */
4
2
 
5
3
  const { CompletionItemKind, InsertTextFormat } = require('@volar/language-server');
6
- const { getVirtualCode, createLogging } = require('./utils.js');
4
+ const { getVirtualCode, createLogging, isInsideImport, isInsideExport } = require('./utils.js');
7
5
 
8
6
  const { log } = createLogging('[Ripple Completion Plugin]');
9
7
 
8
+ /**
9
+ * Snippets that require auto-import from 'ripple'
10
+ * @type {Array<{label: string, filterText: string, detail: string, documentation: string, insertText: string, importName: string | null}>}
11
+ */
12
+ const TRACKED_COLLECTION_SNIPPETS = [
13
+ {
14
+ label: '#Map',
15
+ filterText: '#Map',
16
+ detail: 'Create a Shorthand TrackedMap',
17
+ documentation: 'A reactive Map that triggers updates when modified',
18
+ insertText: 'new #Map(${1})',
19
+ importName: null,
20
+ },
21
+ {
22
+ label: '#Set',
23
+ filterText: '#Set',
24
+ detail: 'Create a Shorthand TrackedSet',
25
+ documentation: 'A reactive Set that triggers updates when modified',
26
+ insertText: 'new #Set(${1})',
27
+ importName: null,
28
+ },
29
+ {
30
+ label: 'TrackedMap',
31
+ filterText: 'TrackedMap',
32
+ detail: 'Create a TrackedMap',
33
+ documentation: 'A reactive Map that triggers updates when modified',
34
+ insertText: 'new TrackedMap(${1})',
35
+ importName: 'TrackedMap',
36
+ },
37
+ {
38
+ label: 'TrackedSet',
39
+ filterText: 'TrackedSet',
40
+ detail: 'Create a TrackedSet',
41
+ documentation: 'A reactive Set that triggers updates when modified',
42
+ insertText: 'new TrackedSet(${1})',
43
+ importName: 'TrackedSet',
44
+ },
45
+ {
46
+ label: 'TrackedArray',
47
+ filterText: 'TrackedArray',
48
+ detail: 'Create a TrackedArray',
49
+ documentation: 'A reactive Array that triggers updates when modified',
50
+ insertText: 'new TrackedArray(${1})',
51
+ importName: 'TrackedArray',
52
+ },
53
+ {
54
+ label: 'TrackedArray.from',
55
+ filterText: 'TrackedArray.from',
56
+ detail: 'Create a TrackedArray.from',
57
+ documentation: 'A reactive Array that triggers when modified',
58
+ insertText: 'new TrackedArray.from(${1})',
59
+ importName: 'TrackedArray',
60
+ },
61
+ {
62
+ label: 'TrackedObject',
63
+ filterText: 'TrackedObject',
64
+ detail: 'Create a TrackedObject',
65
+ documentation: 'A reactive Object that triggers updates when modified',
66
+ insertText: 'new TrackedObject(${1})',
67
+ importName: 'TrackedObject',
68
+ },
69
+ {
70
+ label: 'TrackedDate',
71
+ filterText: 'TrackedDate',
72
+ detail: 'Create a TrackedDate',
73
+ documentation: 'A reactive Date that triggers updates when modified',
74
+ insertText: 'new TrackedDate(${1})',
75
+ importName: 'TrackedDate',
76
+ },
77
+ {
78
+ label: 'TrackedURL',
79
+ filterText: 'TrackedURL',
80
+ detail: 'Create a TrackedURL',
81
+ documentation: 'A reactive URL that triggers updates when modified',
82
+ insertText: 'new TrackedURL(${1})',
83
+ importName: 'TrackedURL',
84
+ },
85
+ {
86
+ label: 'TrackedURLSearchParams',
87
+ filterText: 'TrackedURLSearchParams',
88
+ detail: 'Create a TrackedURLSearchParams',
89
+ documentation: 'A reactive URLSearchParams that triggers updates when modified',
90
+ insertText: 'new TrackedURLSearchParams(${1})',
91
+ importName: 'TrackedURLSearchParams',
92
+ },
93
+ {
94
+ label: 'MediaQuery',
95
+ filterText: 'MediaQuery',
96
+ detail: 'Create a MediaQuery',
97
+ documentation: 'A reactive media query that triggers updates when the query match changes',
98
+ insertText: 'new MediaQuery(${1})',
99
+ importName: 'MediaQuery',
100
+ },
101
+ ];
102
+
103
+ /**
104
+ * Find the ripple import statement in the document
105
+ * @param {string} text - Full document text
106
+ * @returns {{line: number, startChar: number, endChar: number, imports: string[], hasSemicolon: boolean, fullMatch: string} | null}
107
+ */
108
+ function findRippleImport(text) {
109
+ const lines = text.split('\n');
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i];
112
+ // Match: import { x, y, z } from 'ripple'; (with optional semicolon and trailing whitespace)
113
+ const match = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]ripple['"](;?)(\s*)$/);
114
+ if (match) {
115
+ const imports = match[1]
116
+ .split(',')
117
+ .map((s) => s.trim())
118
+ .filter(Boolean);
119
+ return {
120
+ line: i,
121
+ startChar: 0,
122
+ endChar: line.length,
123
+ imports,
124
+ hasSemicolon: match[2] === ';',
125
+ fullMatch: line,
126
+ };
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Generate additionalTextEdits to add an import
134
+ * @param {string} documentText - Full document text
135
+ * @param {string} importName - Name to import (e.g., 'TrackedMap')
136
+ * @returns {TextEdit[]}
137
+ */
138
+ function generateImportEdit(documentText, importName) {
139
+ const existing = findRippleImport(documentText);
140
+
141
+ if (existing) {
142
+ // Check if already imported
143
+ if (existing.imports.includes(importName)) {
144
+ return []; // Already imported, no edit needed
145
+ }
146
+ // Add to existing import, preserving semicolon status
147
+ const newImports = [...existing.imports, importName].sort().join(', ');
148
+ const semicolon = existing.hasSemicolon ? ';' : '';
149
+ const newLine = `import { ${newImports} } from 'ripple'${semicolon}`;
150
+ return [
151
+ {
152
+ range: {
153
+ start: { line: existing.line, character: 0 },
154
+ end: { line: existing.line, character: existing.endChar },
155
+ },
156
+ newText: newLine,
157
+ },
158
+ ];
159
+ }
160
+
161
+ // No existing ripple import - add new one at the top
162
+ // Find the best insertion point (after other imports, or at line 0)
163
+ const lines = documentText.split('\n');
164
+ let insertLine = 0;
165
+ for (let i = 0; i < lines.length; i++) {
166
+ if (lines[i].match(/^import\s/)) {
167
+ insertLine = i + 1; // Insert after last import
168
+ } else if (insertLine > 0 && !lines[i].match(/^import\s/) && lines[i].trim() !== '') {
169
+ break; // Stop if we've passed the import block
170
+ }
171
+ }
172
+
173
+ return [
174
+ {
175
+ range: {
176
+ start: { line: insertLine, character: 0 },
177
+ end: { line: insertLine, character: 0 },
178
+ },
179
+ newText: `import { ${importName} } from 'ripple';\n`,
180
+ },
181
+ ];
182
+ }
183
+
10
184
  /**
11
185
  * Ripple-specific completion enhancements
12
186
  * Adds custom completions for Ripple syntax patterns
13
187
  */
14
188
  const RIPPLE_SNIPPETS = [
189
+ {
190
+ label: '#[]',
191
+ kind: CompletionItemKind.Snippet,
192
+ detail: 'Ripple Reactive Array Literal, shorthand for new TrackedArray',
193
+ documentation: 'Create a new Ripple Array Literal',
194
+ insertText: '#[${1}]',
195
+ insertTextFormat: InsertTextFormat.Snippet,
196
+ sortText: '0-#-array-literal',
197
+ },
198
+ {
199
+ label: '#{}',
200
+ kind: CompletionItemKind.Snippet,
201
+ detail: 'Ripple Reactive Object Literal, shorthand for new TrackedObject',
202
+ documentation: 'Create a new Ripple Object Literal',
203
+ insertText: '#{${1}}',
204
+ insertTextFormat: InsertTextFormat.Snippet,
205
+ sortText: '0-#-object-literal',
206
+ },
15
207
  {
16
208
  label: 'component',
17
209
  kind: CompletionItemKind.Snippet,
@@ -201,8 +393,9 @@ function createCompletionPlugin() {
201
393
  completionProvider: {
202
394
  // Trigger on Ripple-specific syntax:
203
395
  // '<' - JSX/HTML tags
396
+ // '#' - TrackedMap/TrackedSet shortcuts
204
397
  // Avoid '.' and ' ' to reduce noise - let manual trigger (Ctrl+Space) handle those
205
- triggerCharacters: ['<'],
398
+ triggerCharacters: ['<', '#'],
206
399
  resolveProvider: false,
207
400
  },
208
401
  },
@@ -219,10 +412,10 @@ function createCompletionPlugin() {
219
412
 
220
413
  const [virtualCode] = getVirtualCode(document, context);
221
414
 
222
- // Check if we're inside an embedded code (like CSS in <style> blocks)
223
- // If so, don't provide Ripple snippets - let CSS completions take priority
224
- if (virtualCode && virtualCode.languageId === 'css') {
225
- log('Skipping Ripple completions in CSS context');
415
+ if (virtualCode.languageId !== 'ripple') {
416
+ // Check if we're inside an embedded code (like CSS in <style> blocks)
417
+ // If so, don't provide Ripple snippets - let CSS completions take priority
418
+ log(`Skipping Ripple completions in the '${virtualCode.languageId}' context`);
226
419
  return { items: [], isIncomplete: false };
227
420
  }
228
421
 
@@ -231,6 +424,7 @@ function createCompletionPlugin() {
231
424
  end: position,
232
425
  });
233
426
 
427
+ /** @type {CompletionItem[]} */
234
428
  const items = [];
235
429
 
236
430
  // Debug: log trigger info with clear marker
@@ -250,9 +444,14 @@ function createCompletionPlugin() {
250
444
  lineEnd: line.substring(Math.max(0, line.length - 30)),
251
445
  });
252
446
 
253
- // Import completions when line starts with 'import'
254
- if (line.trim().startsWith('import')) {
447
+ const fullText = document.getText();
448
+ const cursorOffset = document.offsetAt(position);
449
+
450
+ if (isInsideImport(fullText, cursorOffset)) {
255
451
  items.push(...RIPPLE_IMPORTS);
452
+ return { items, isIncomplete: false };
453
+ } else if (isInsideExport(fullText, cursorOffset)) {
454
+ return { items, isIncomplete: false };
256
455
  }
257
456
 
258
457
  // @ accessor hint when typing after @
@@ -265,6 +464,53 @@ function createCompletionPlugin() {
265
464
  });
266
465
  }
267
466
 
467
+ // TrackedMap/TrackedSet completions when typing T...
468
+ // Also detects if 'new' is already typed before it to avoid duplicating
469
+ const trackedMatch = line.match(/(new\s+)?[T,M,#]([\w\.]*)$/);
470
+
471
+ if (trackedMatch) {
472
+ const hasNew = !!trackedMatch[1];
473
+ const typed = trackedMatch[2].toLowerCase();
474
+
475
+ for (const snippet of TRACKED_COLLECTION_SNIPPETS) {
476
+ // Match if typing matches start of 'rackedMap', 'rackedSet' (after T)
477
+ const afterT = snippet.label.slice(1).toLowerCase(); // 'rackedmap' or 'rackedset'
478
+ if (typed === '' || afterT.startsWith(typed)) {
479
+ // Determine insert text - skip 'new ' if already present
480
+ const insertText = hasNew
481
+ ? `${snippet.label}(\${1})`
482
+ : `new ${snippet.label}(\${1})`;
483
+
484
+ items.push({
485
+ label: snippet.label,
486
+ filterText: snippet.filterText,
487
+ kind: CompletionItemKind.Snippet,
488
+ detail: snippet.detail,
489
+ documentation: snippet.documentation,
490
+ insertText: insertText,
491
+ insertTextFormat: InsertTextFormat.Snippet,
492
+ sortText: '0-' + snippet.label.toLowerCase(),
493
+ // Replace 'T...' or 'new T...' depending on what was typed
494
+ textEdit: {
495
+ range: {
496
+ start: {
497
+ line: position.line,
498
+ character: position.character - trackedMatch[0].length,
499
+ },
500
+ end: position,
501
+ },
502
+ newText: insertText,
503
+ },
504
+ additionalTextEdits: snippet
505
+ ? snippet.importName != null
506
+ ? generateImportEdit(fullText, snippet.importName)
507
+ : undefined
508
+ : undefined,
509
+ });
510
+ }
511
+ }
512
+ }
513
+
268
514
  // Ripple keywords - extract the last word being typed
269
515
  const wordMatch = line.match(/(\w+)$/);
270
516
  const currentWord = wordMatch ? wordMatch[1] : '';
@@ -4,8 +4,19 @@
4
4
 
5
5
  const { TextDocument } = require('vscode-languageserver-textdocument');
6
6
  const { getVirtualCode, createLogging, getWordFromPosition } = require('./utils.js');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+
10
+ const {
11
+ normalizeFileNameOrUri,
12
+ getRippleDirForFile,
13
+ getCachedTypeDefinitionFile,
14
+ getCachedTypeMatches,
15
+ } = require('@ripple-ts/typescript-plugin/src/language.js');
7
16
 
8
17
  const { log } = createLogging('[Ripple Definition Plugin]');
18
+ /** @type {string | undefined} */
19
+ let ripple_dir;
9
20
 
10
21
  /**
11
22
  * @returns {LanguageServicePlugin}
@@ -34,6 +45,12 @@ function createDefinitionPlugin() {
34
45
 
35
46
  const [virtualCode, sourceUri] = getVirtualCode(document, context);
36
47
 
48
+ if (virtualCode.languageId !== 'ripple') {
49
+ // like embedded css
50
+ log(`Skipping definitions processing in the '${virtualCode.languageId}' context`);
51
+ return tsDefinitions;
52
+ }
53
+
37
54
  // First check for custom definitions (e.g., CSS class selectors)
38
55
  const offset = document.offsetAt(position);
39
56
  const text = document.getText();
@@ -44,7 +61,78 @@ function createDefinitionPlugin() {
44
61
  log(`Cursor position in generated code for word '${word}':`, position);
45
62
  log(`Cursor offset in generated code for word '${word}':`, offset);
46
63
 
47
- // If mapping has custom definition metadata with location, handle it
64
+ // Handle `typeReplace` definitions
65
+ if (
66
+ customMapping?.data.customData.definition !== false &&
67
+ customMapping?.data.customData.definition?.typeReplace
68
+ ) {
69
+ const { name: typeName, path: typePath } =
70
+ customMapping.data.customData.definition.typeReplace;
71
+
72
+ log(`Found replace definition for ${typeName}`);
73
+
74
+ const filePath = sourceUri.fsPath || sourceUri.path;
75
+ ripple_dir = ripple_dir ?? getRippleDirForFile(normalizeFileNameOrUri(filePath));
76
+
77
+ if (!ripple_dir) {
78
+ log(`Could not determine Ripple source directory for file: ${filePath}`);
79
+ return;
80
+ }
81
+
82
+ const typesFilePath = path.join(ripple_dir, ...typePath.split('/'));
83
+
84
+ const fileContent = getCachedTypeDefinitionFile(typesFilePath);
85
+
86
+ if (!fileContent) {
87
+ // the `getCachedTypeDefinitionFile` already logs the error
88
+ return;
89
+ }
90
+
91
+ const match = getCachedTypeMatches(typeName, fileContent);
92
+
93
+ if (match && match.index !== undefined) {
94
+ const classStart = match.index + match[0].indexOf(typeName);
95
+ const classEnd = classStart + typeName.length;
96
+
97
+ // Convert offset to line/column
98
+ const lines = fileContent.substring(0, classStart).split('\n');
99
+ const line = lines.length - 1;
100
+ const character = lines[lines.length - 1].length;
101
+
102
+ const endLines = fileContent.substring(0, classEnd).split('\n');
103
+ const endLine = endLines.length - 1;
104
+ const endCharacter = endLines[endLines.length - 1].length;
105
+
106
+ // Create the origin selection range for #Map/#Set
107
+ const generatedStart = customMapping.generatedOffsets[0];
108
+ const generatedEnd =
109
+ generatedStart + customMapping.data.customData.generatedLengths[0];
110
+ const originStart = document.positionAt(generatedStart);
111
+ const originEnd = document.positionAt(generatedEnd);
112
+
113
+ /** @type {LocationLink} */
114
+ const locationLink = {
115
+ targetUri: `file://${typesFilePath}`,
116
+ targetRange: {
117
+ start: { line, character },
118
+ end: { line: endLine, character: endCharacter },
119
+ },
120
+ targetSelectionRange: {
121
+ start: { line, character },
122
+ end: { line: endLine, character: endCharacter },
123
+ },
124
+ originSelectionRange: {
125
+ start: originStart,
126
+ end: originEnd,
127
+ },
128
+ };
129
+
130
+ log(`Created definition link to ${typesFilePath}:${line}:${character}`);
131
+ return [locationLink];
132
+ }
133
+ }
134
+
135
+ // Handle embedded code definition location, e.g. CSS class selectors
48
136
  if (
49
137
  customMapping?.data.customData.definition !== false &&
50
138
  customMapping?.data.customData.definition?.location
@@ -115,8 +203,11 @@ function createDefinitionPlugin() {
115
203
  }
116
204
  }
117
205
 
118
- // Below here we handle adjusting TypeScript definitions for transformed tokens
119
- // specifically, when "component" in Ripple maps to "function" in TS
206
+ // Below here we handle adjusting TS definition for transformed tokens
207
+ // `originSelectionRange` returned by TS needs its end character adjusted
208
+ // to account for the source length differing from generated length
209
+ // e.g. when "component" in Ripple maps to "function" in TS
210
+ // Or when "#Map" maps to "TrackedMap", etc.
120
211
 
121
212
  // If no TypeScript definitions, nothing to modify
122
213
  // Volar will let the next ts plugin handle it
@@ -144,13 +235,15 @@ function createDefinitionPlugin() {
144
235
 
145
236
  log('Found mapping for definition at range', 'start: ', rangeStart, 'end: ', rangeEnd);
146
237
 
147
- // Check if source length is greater than generated length (component -> function)
238
+ // Check if source length differs from generated length
239
+ // e.g., "component" -> "function" (source > generated)
240
+ // e.g., "#Map" -> "TrackedMap" (source < generated)
148
241
  const customData = mapping.data.customData;
149
242
  const sourceLength = mapping.lengths[0];
150
243
  const generatedLength = customData.generatedLengths[0];
151
244
 
152
- // If no generatedLengths, or source and generated are same length, no transformation
153
- if (sourceLength <= generatedLength) {
245
+ // If source and generated are same length, no transformation needed
246
+ if (sourceLength === generatedLength) {
154
247
  return tsDefinitions;
155
248
  }
156
249
 
@@ -58,7 +58,8 @@ function createDocumentHighlightPlugin() {
58
58
 
59
59
  const [virtualCode] = getVirtualCode(document, context);
60
60
 
61
- if (!virtualCode) {
61
+ if (virtualCode.languageId !== 'ripple') {
62
+ log(`Skipping highlight processing in the '${virtualCode.languageId}' context`);
62
63
  return tsHighlights;
63
64
  }
64
65
 
@@ -10,6 +10,7 @@ const {
10
10
  createLogging,
11
11
  getWordFromPosition,
12
12
  concatMarkdownContents,
13
+ deobfuscateImportDefinitions,
13
14
  } = require('./utils.js');
14
15
 
15
16
  const { log, logError } = createLogging('[Ripple Hover Plugin]');
@@ -53,6 +54,12 @@ function createHoverPlugin() {
53
54
  tsHover = await originalProvideHover.call(originalInstance, document, position, token);
54
55
  }
55
56
 
57
+ if (tsHover && tsHover.contents) {
58
+ /** @type {MarkupContent} **/ (tsHover.contents).value = deobfuscateImportDefinitions(
59
+ /** @type {MarkupContent} **/ (tsHover.contents).value,
60
+ );
61
+ }
62
+
56
63
  const [virtualCode] = getVirtualCode(document, context);
57
64
 
58
65
  if (!virtualCode) {
@@ -79,6 +86,11 @@ function createHoverPlugin() {
79
86
  log(`Cursor offset in generated code for word '${word}':`, offset);
80
87
  }
81
88
 
89
+ if (virtualCode.languageId !== 'ripple') {
90
+ log(`Skipping hover processing in the '${virtualCode.languageId}' context`);
91
+ return tsHover;
92
+ }
93
+
82
94
  const mapping = virtualCode.findMappingByGeneratedRange(starOffset, endOffset);
83
95
 
84
96
  if (!mapping) {
@@ -1,11 +1,77 @@
1
- /** @import { LanguageServicePlugin } from '@volar/language-server' */
2
- /** @import { LanguageServicePluginInstance } from '@volar/language-server' */
1
+ /**
2
+ @import {
3
+ LanguageServicePlugin,
4
+ LanguageServicePluginInstance,
5
+ LanguageServiceContext,
6
+ Diagnostic,
7
+ } from '@volar/language-server';
8
+ @import {TextDocument} from 'vscode-languageserver-textdocument';
9
+ */
3
10
 
4
11
  const { getVirtualCode, createLogging } = require('./utils.js');
5
12
 
6
13
  const { log, logError } = createLogging('[Ripple TypeScript Diagnostic Plugin]');
7
14
 
8
15
  /**
16
+ * Filter diagnostics based on suppressed diagnostic codes in mappings.
17
+ * @param {TextDocument} document
18
+ * @param {LanguageServiceContext} context
19
+ * @param {Diagnostic[]} diagnostics
20
+ * @returns {Diagnostic[]}
21
+ */
22
+ function filterDiagnostics(document, context, diagnostics) {
23
+ if (!diagnostics || diagnostics.length === 0) {
24
+ return diagnostics;
25
+ }
26
+
27
+ log(`Filtering ${diagnostics.length} TypeScript diagnostics for ${document.uri}`);
28
+
29
+ const [virtualCode] = getVirtualCode(document, context);
30
+
31
+ if (!virtualCode) {
32
+ return diagnostics;
33
+ }
34
+
35
+ const filtered = diagnostics.filter((diagnostic) => {
36
+ const range = diagnostic.range;
37
+ const rangeStart = document.offsetAt(range.start);
38
+ const rangeEnd = document.offsetAt(range.end);
39
+ const mapping = virtualCode.findMappingByGeneratedRange(rangeStart, rangeEnd);
40
+
41
+ if (!mapping) {
42
+ return true;
43
+ }
44
+
45
+ const suppressedCodes = mapping.data.customData?.suppressedDiagnostics;
46
+
47
+ if (!suppressedCodes || suppressedCodes.length === 0) {
48
+ return true;
49
+ }
50
+
51
+ const diagnosticCode =
52
+ typeof diagnostic.code === 'number'
53
+ ? diagnostic.code
54
+ : typeof diagnostic.code === 'string'
55
+ ? parseInt(diagnostic.code)
56
+ : null;
57
+
58
+ if (diagnosticCode && suppressedCodes.includes(diagnosticCode)) {
59
+ log(`Suppressing diagnostic ${diagnosticCode}: ${diagnostic.message}`);
60
+ return false;
61
+ }
62
+
63
+ return true;
64
+ });
65
+
66
+ log(`Filtered from ${diagnostics.length} to ${filtered.length} diagnostics`);
67
+ return filtered;
68
+ }
69
+
70
+ /**
71
+ * Creates a plugin that wraps typescript-semantic's provideDiagnostics
72
+ * to filter out suppressed diagnostics while maintaining the original
73
+ * plugin association. This is crucial for code actions (like "Add import")
74
+ * to work correctly, as volar matches diagnostics by pluginIndex.
9
75
  * @returns {LanguageServicePlugin}
10
76
  */
11
77
  function createTypeScriptDiagnosticFilterPlugin() {
@@ -13,87 +79,42 @@ function createTypeScriptDiagnosticFilterPlugin() {
13
79
 
14
80
  return {
15
81
  name: 'ripple-typescript-diagnostic-filter',
16
- capabilities: {
17
- diagnosticProvider: {
18
- interFileDependencies: false,
19
- workspaceDiagnostics: false,
20
- },
21
- },
82
+ // No capabilities - this plugin only wraps typescript-semantic
83
+ capabilities: {},
22
84
  create(context) {
23
- /** @type {LanguageServicePluginInstance['provideDiagnostics']} */
24
- let originalProvideDiagnostics;
25
- /** @type {LanguageServicePluginInstance} */
85
+ /** @type {LanguageServicePluginInstance['provideDiagnostics'] | undefined} */
86
+ let originalProvider;
87
+ /** @type {LanguageServicePluginInstance | undefined} */
26
88
  let originalInstance;
27
89
 
28
- // Disable typescript-semantic's provideDiagnostics so it doesn't merge with ours
29
90
  for (const [plugin, instance] of context.plugins) {
30
91
  if (plugin.name === 'typescript-semantic') {
31
92
  originalInstance = instance;
32
- originalProvideDiagnostics = instance.provideDiagnostics;
33
- instance.provideDiagnostics = undefined;
93
+ originalProvider = instance.provideDiagnostics;
94
+
95
+ // Wrap the original function to filter diagnostics
96
+ // This maintains the plugin association for code actions
97
+ instance.provideDiagnostics = async function (document, token) {
98
+ const diagnostics = await originalProvider?.call(originalInstance, document, token);
99
+ return filterDiagnostics(document, context, diagnostics ?? []);
100
+ };
101
+
102
+ log('Successfully wrapped typescript-semantic provideDiagnostics');
103
+
34
104
  break;
35
105
  }
36
106
  }
37
107
 
38
- if (!originalProvideDiagnostics) {
108
+ if (!originalProvider) {
39
109
  logError(
40
110
  "'typescript-semantic plugin' was not found or has no 'provideDiagnostics'. \
41
111
  This plugin must be loaded after Volar's typescript-semantic plugin.",
42
112
  );
43
113
  }
44
114
 
45
- return {
46
- async provideDiagnostics(document, token) {
47
- let diagnostics;
48
-
49
- if (originalProvideDiagnostics) {
50
- diagnostics = await originalProvideDiagnostics.call(originalInstance, document, token);
51
- }
52
-
53
- if (!diagnostics || diagnostics.length === 0) {
54
- return diagnostics;
55
- }
56
-
57
- log(`Filtering ${diagnostics.length} TypeScript diagnostics for ${document.uri}`);
58
-
59
- const [virtualCode] = getVirtualCode(document, context);
60
-
61
- const filtered = diagnostics.filter((diagnostic) => {
62
- const range = diagnostic.range;
63
- const rangeStart = document.offsetAt(range.start);
64
- const rangeEnd = document.offsetAt(range.end);
65
- // Get the mapping at this diagnostic position
66
- const mapping = virtualCode.findMappingByGeneratedRange(rangeStart, rangeEnd);
67
-
68
- if (!mapping) {
69
- return true;
70
- }
71
-
72
- const suppressedCodes = mapping.data.customData?.suppressedDiagnostics;
73
-
74
- if (!suppressedCodes || suppressedCodes.length === 0) {
75
- return true;
76
- }
77
-
78
- const diagnosticCode =
79
- typeof diagnostic.code === 'number'
80
- ? diagnostic.code
81
- : typeof diagnostic.code === 'string'
82
- ? parseInt(diagnostic.code)
83
- : null;
84
-
85
- if (diagnosticCode && suppressedCodes.includes(diagnosticCode)) {
86
- log(`Suppressing diagnostic ${diagnosticCode}: ${diagnostic.message}`);
87
- return false; // Filter out this diagnostic
88
- }
89
-
90
- return true; // Keep this diagnostic
91
- });
92
-
93
- log(`Filtered from ${diagnostics.length} to ${filtered.length} diagnostics`);
94
- return filtered;
95
- },
96
- };
115
+ // This plugin doesn't provide any functionality itself,
116
+ // it only wraps typescript-semantic
117
+ return {};
97
118
  },
98
119
  };
99
120
  }
package/src/utils.js CHANGED
@@ -1,10 +1,60 @@
1
1
  /** @import { TextDocument } from 'vscode-languageserver-textdocument' */
2
2
  /** @import { LanguageServiceContext } from '@volar/language-server' */
3
3
  /** @import {RippleVirtualCode} from '@ripple-ts/typescript-plugin/src/language.js' */
4
+ // @ts-expect-error: ESM type import is fine
5
+ /** @import {is_imported_obfuscated, deobfuscate_imported, IMPORT_OBFUSCATION_PREFIX} from 'ripple/compiler/internal/import/utils' */
4
6
 
5
7
  const { URI } = require('vscode-uri');
6
8
  const { createLogging, DEBUG } = require('@ripple-ts/typescript-plugin/src/utils.js');
7
- const wordRegex = /\w/;
9
+ // Matches valid JS/CSS identifier characters: word chars, dashes (CSS), $, and # (Ripple shorthands)
10
+ const charAllowedWordRegex = /[\w\-$#]/;
11
+ const IMPORT_EXPORT_REGEX = {
12
+ import: {
13
+ findBefore: /import\s+(?:\{[^}]*|\*\s+as\s+\w*|\w*)$/s,
14
+ sameLine: /^import\s/,
15
+ },
16
+ export: {
17
+ findBefore: /export\s+(?:\{[^}]*|\*\s+as\s+\w*|\w*)$/s,
18
+ sameLine: /^export\s/,
19
+ },
20
+ from: /from\s*['"][^'"]*['"]\s*;?/,
21
+ };
22
+
23
+ /** @type {is_imported_obfuscated} */
24
+ let is_imported_obfuscated;
25
+ /** @type {deobfuscate_imported} */
26
+ let deobfuscate_imported;
27
+ /** @type {IMPORT_OBFUSCATION_PREFIX} */
28
+ let IMPORT_OBFUSCATION_PREFIX;
29
+ /** @type {RegExp} */
30
+ let obfuscatedImportRegex;
31
+
32
+ import('ripple/compiler/internal/import/utils').then((imports) => {
33
+ is_imported_obfuscated = imports.is_imported_obfuscated;
34
+ deobfuscate_imported = imports.deobfuscate_imported;
35
+ IMPORT_OBFUSCATION_PREFIX = imports.IMPORT_OBFUSCATION_PREFIX;
36
+ obfuscatedImportRegex = new RegExp(
37
+ escapeRegExp(IMPORT_OBFUSCATION_PREFIX) + charAllowedWordRegex.source + '+',
38
+ 'gm',
39
+ );
40
+ });
41
+
42
+ /**
43
+ * @param {string} source
44
+ * @returns {string}
45
+ */
46
+ function escapeRegExp(source) {
47
+ // $& means the whole matched source
48
+ return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ }
50
+
51
+ /**
52
+ * @param {string} text
53
+ * @returns {string}
54
+ */
55
+ function deobfuscateImportDefinitions(text) {
56
+ return text.replace(obfuscatedImportRegex, (match) => deobfuscate_imported(match));
57
+ }
8
58
 
9
59
  /**
10
60
  * @param {...string} contents
@@ -43,10 +93,10 @@ function getVirtualCode(document, context) {
43
93
  function getWordFromPosition(text, start) {
44
94
  let wordStart = start;
45
95
  let wordEnd = start;
46
- while (wordStart > 0 && wordRegex.test(text[wordStart - 1])) {
96
+ while (wordStart > 0 && charAllowedWordRegex.test(text[wordStart - 1])) {
47
97
  wordStart--;
48
98
  }
49
- while (wordEnd < text.length && wordRegex.test(text[wordEnd])) {
99
+ while (wordEnd < text.length && charAllowedWordRegex.test(text[wordEnd])) {
50
100
  wordEnd++;
51
101
  }
52
102
 
@@ -59,10 +109,66 @@ function getWordFromPosition(text, start) {
59
109
  };
60
110
  }
61
111
 
112
+ /**
113
+ * @param {'import' | 'export'} type
114
+ * @param {string} text
115
+ * @param {number} start
116
+ * @returns {boolean}
117
+ */
118
+ function isInsideImportOrExport(type, text, start) {
119
+ const textBeforeCursor = text.slice(0, start);
120
+
121
+ // Find the last 'import' keyword before cursor
122
+ const lastImportMatch = textBeforeCursor.match(IMPORT_EXPORT_REGEX[type].findBefore);
123
+ if (!lastImportMatch) {
124
+ // Check if we're on a line that starts with import
125
+ const lineStart = textBeforeCursor.lastIndexOf('\n') + 1;
126
+ const lineBeforeCursor = textBeforeCursor.slice(lineStart);
127
+ return IMPORT_EXPORT_REGEX[type].sameLine.test(lineBeforeCursor.trim());
128
+ }
129
+
130
+ // We found an import - check if it's been closed with 'from'
131
+ const importStart = textBeforeCursor.lastIndexOf(type);
132
+ const textFromImport = text.slice(importStart);
133
+
134
+ // Find the end of this import statement (semicolon or newline after 'from "..."')
135
+ const fromMatch = textFromImport.match(IMPORT_EXPORT_REGEX.from);
136
+ if (!fromMatch || fromMatch.index === undefined) {
137
+ // No 'from' found yet - we're inside an incomplete import
138
+ return true;
139
+ }
140
+
141
+ const importEndOffset = importStart + fromMatch.index + fromMatch[0].length;
142
+
143
+ // If cursor is before the import ends, we're inside it
144
+ return start < importEndOffset;
145
+ }
146
+
147
+ /**
148
+ * @param {string} text
149
+ * @param {number} start
150
+ * @returns {boolean}
151
+ */
152
+ function isInsideImport(text, start) {
153
+ return isInsideImportOrExport('import', text, start);
154
+ }
155
+
156
+ /**
157
+ * @param {string} text
158
+ * @param {number} start
159
+ * @returns {boolean}
160
+ */
161
+ function isInsideExport(text, start) {
162
+ return isInsideImportOrExport('export', text, start);
163
+ }
164
+
62
165
  module.exports = {
63
166
  getVirtualCode,
64
167
  getWordFromPosition,
168
+ isInsideImport,
169
+ isInsideExport,
65
170
  createLogging,
66
171
  concatMarkdownContents,
172
+ deobfuscateImportDefinitions,
67
173
  DEBUG,
68
174
  };