@ripple-ts/language-server 0.3.41 → 0.3.42

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,508 +0,0 @@
1
- /** @import { LanguageServicePlugin, TextEdit, CompletionItem } from '@volar/language-server'; */
2
-
3
- import { CompletionItemKind, InsertTextFormat } from '@volar/language-server';
4
- import {
5
- getVirtualCode,
6
- createLogging,
7
- isInsideImport,
8
- isInsideExport,
9
- is_ripple_document,
10
- } from './utils.js';
11
-
12
- const { log } = createLogging('[Ripple Completion Plugin]');
13
-
14
- /**
15
- * Snippets that require auto-import from 'ripple'
16
- * @type {Array<{label: string, filterText: string, detail: string, documentation: string, insertText: string, importName: string | null}>}
17
- */
18
- const TRACKED_COLLECTION_SNIPPETS = [
19
- {
20
- label: 'RippleMap',
21
- filterText: 'RippleMap',
22
- detail: 'Create a RippleMap',
23
- documentation: 'A reactive Map that triggers updates when modified',
24
- insertText: 'new RippleMap(${1})',
25
- importName: 'RippleMap',
26
- },
27
- {
28
- label: 'RippleSet',
29
- filterText: 'RippleSet',
30
- detail: 'Create a RippleSet',
31
- documentation: 'A reactive Set that triggers updates when modified',
32
- insertText: 'new RippleSet(${1})',
33
- importName: 'RippleSet',
34
- },
35
- {
36
- label: 'RippleArray',
37
- filterText: 'RippleArray',
38
- detail: 'Create a RippleArray',
39
- documentation: 'A reactive Array that triggers updates when modified',
40
- insertText: 'new RippleArray(${1})',
41
- importName: 'RippleArray',
42
- },
43
- {
44
- label: 'RippleArray.from',
45
- filterText: 'RippleArray.from',
46
- detail: 'Create a RippleArray.from',
47
- documentation: 'A reactive Array that triggers when modified',
48
- insertText: 'new RippleArray.from(${1})',
49
- importName: 'RippleArray',
50
- },
51
- {
52
- label: 'RippleObject',
53
- filterText: 'RippleObject',
54
- detail: 'Create a RippleObject',
55
- documentation: 'A reactive Object that triggers updates when modified',
56
- insertText: 'new RippleObject(${1})',
57
- importName: 'RippleObject',
58
- },
59
- {
60
- label: 'RippleDate',
61
- filterText: 'RippleDate',
62
- detail: 'Create a RippleDate',
63
- documentation: 'A reactive Date that triggers updates when modified',
64
- insertText: 'new RippleDate(${1})',
65
- importName: 'RippleDate',
66
- },
67
- {
68
- label: 'RippleURL',
69
- filterText: 'RippleURL',
70
- detail: 'Create a RippleURL',
71
- documentation: 'A reactive URL that triggers updates when modified',
72
- insertText: 'new RippleURL(${1})',
73
- importName: 'RippleURL',
74
- },
75
- {
76
- label: 'RippleURLSearchParams',
77
- filterText: 'RippleURLSearchParams',
78
- detail: 'Create a RippleURLSearchParams',
79
- documentation: 'A reactive URLSearchParams that triggers updates when modified',
80
- insertText: 'new RippleURLSearchParams(${1})',
81
- importName: 'RippleURLSearchParams',
82
- },
83
- {
84
- label: 'MediaQuery',
85
- filterText: 'MediaQuery',
86
- detail: 'Create a MediaQuery',
87
- documentation: 'A reactive media query that triggers updates when the query match changes',
88
- insertText: 'new MediaQuery(${1})',
89
- importName: 'MediaQuery',
90
- },
91
- ];
92
-
93
- /**
94
- * Find the ripple import statement in the document
95
- * @param {string} text - Full document text
96
- * @returns {{line: number, startChar: number, endChar: number, imports: string[], hasSemicolon: boolean, fullMatch: string} | null}
97
- */
98
- function findRippleImport(text) {
99
- const lines = text.split('\n');
100
- for (let i = 0; i < lines.length; i++) {
101
- const line = lines[i];
102
- // Match: import { x, y, z } from 'ripple'; (with optional semicolon and trailing whitespace)
103
- const match = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]ripple['"](;?)(\s*)$/);
104
- if (match) {
105
- const imports = match[1]
106
- .split(',')
107
- .map((s) => s.trim())
108
- .filter(Boolean);
109
- return {
110
- line: i,
111
- startChar: 0,
112
- endChar: line.length,
113
- imports,
114
- hasSemicolon: match[2] === ';',
115
- fullMatch: line,
116
- };
117
- }
118
- }
119
- return null;
120
- }
121
-
122
- /**
123
- * Generate additionalTextEdits to add an import
124
- * @param {string} documentText - Full document text
125
- * @param {string} importName - Name to import (e.g., 'RippleMap')
126
- * @returns {TextEdit[]}
127
- */
128
- function generateImportEdit(documentText, importName) {
129
- const existing = findRippleImport(documentText);
130
-
131
- if (existing) {
132
- // Check if already imported
133
- if (existing.imports.includes(importName)) {
134
- return []; // Already imported, no edit needed
135
- }
136
- // Add to existing import, preserving semicolon status
137
- const newImports = [...existing.imports, importName].sort().join(', ');
138
- const semicolon = existing.hasSemicolon ? ';' : '';
139
- const newLine = `import { ${newImports} } from 'ripple'${semicolon}`;
140
- return [
141
- {
142
- range: {
143
- start: { line: existing.line, character: 0 },
144
- end: { line: existing.line, character: existing.endChar },
145
- },
146
- newText: newLine,
147
- },
148
- ];
149
- }
150
-
151
- // No existing ripple import - add new one at the top
152
- // Find the best insertion point (after other imports, or at line 0)
153
- const lines = documentText.split('\n');
154
- let insertLine = 0;
155
- for (let i = 0; i < lines.length; i++) {
156
- if (lines[i].match(/^import\s/)) {
157
- insertLine = i + 1; // Insert after last import
158
- } else if (insertLine > 0 && !lines[i].match(/^import\s/) && lines[i].trim() !== '') {
159
- break; // Stop if we've passed the import block
160
- }
161
- }
162
-
163
- return [
164
- {
165
- range: {
166
- start: { line: insertLine, character: 0 },
167
- end: { line: insertLine, character: 0 },
168
- },
169
- newText: `import { ${importName} } from 'ripple';\n`,
170
- },
171
- ];
172
- }
173
-
174
- /**
175
- * Ripple-specific completion enhancements
176
- * Adds custom completions for Ripple syntax patterns
177
- */
178
- const RIPPLE_SNIPPETS = [
179
- {
180
- label: '#style',
181
- kind: CompletionItemKind.Snippet,
182
- detail: 'Scoped CSS class reference',
183
- documentation:
184
- 'Produces a scoped CSS class string for passing to child components.\nThe class must be defined as a standalone selector in <style>.\n\nUsage: <Child cls={#style.highlight} />',
185
- insertText: '#style.${1:className}',
186
- insertTextFormat: InsertTextFormat.Snippet,
187
- sortText: '0-#-style',
188
- },
189
- {
190
- label: '#server',
191
- kind: CompletionItemKind.Snippet,
192
- detail: 'Server-only code block (module level)',
193
- documentation:
194
- 'Marks a block as server-only. Code inside is tree-shaken on the client.\nMust be at module top level.\n\nUsage:\n#server {\n export async function loadData() { ... }\n}',
195
- insertText: '#server {\n\t$0\n}',
196
- insertTextFormat: InsertTextFormat.Snippet,
197
- sortText: '0-#-server',
198
- },
199
- {
200
- label: 'component',
201
- kind: CompletionItemKind.Snippet,
202
- detail: 'Ripple Component',
203
- documentation: 'Create a new Ripple component',
204
- insertText: 'component ${1:ComponentName}(${2:props}) {\n\t$0\n}',
205
- insertTextFormat: InsertTextFormat.Snippet,
206
- sortText: '0-component',
207
- },
208
- {
209
- label: 'track',
210
- kind: CompletionItemKind.Snippet,
211
- detail: 'Reactive state with track',
212
- documentation: 'Create a reactive tracked value',
213
- insertText: 'let ${1:name} = track(${2:initialValue});',
214
- insertTextFormat: InsertTextFormat.Snippet,
215
- sortText: '0-track',
216
- },
217
- {
218
- label: 'track-derived',
219
- kind: CompletionItemKind.Snippet,
220
- detail: 'Derived reactive value',
221
- documentation: 'Create a derived reactive value',
222
- insertText: 'let ${1:name} = track(() => ${2:@dependency});',
223
- insertTextFormat: InsertTextFormat.Snippet,
224
- sortText: '0-track-derived',
225
- },
226
- {
227
- label: 'track-getter-setter',
228
- kind: CompletionItemKind.Snippet,
229
- detail: 'track with get/set',
230
- documentation: 'Create tracked value with custom getter/setter',
231
- insertText:
232
- 'let ${1:name} = track(${2:0},\n\t(current) => {\n\t\t$3\n\t\treturn current;\n\t},\n\t(next, prev) => {\n\t\t$4\n\t\treturn next;\n\t}\n);',
233
- insertTextFormat: InsertTextFormat.Snippet,
234
- sortText: '0-track-getter-setter',
235
- },
236
- {
237
- label: 'effect',
238
- kind: CompletionItemKind.Snippet,
239
- detail: 'Create an effect',
240
- documentation: 'Run side effects when reactive dependencies change',
241
- insertText: 'effect(() => {\n\t${1:console.log(@value);}\n});',
242
- insertTextFormat: InsertTextFormat.Snippet,
243
- sortText: '0-effect',
244
- },
245
- {
246
- label: 'for-of',
247
- kind: CompletionItemKind.Snippet,
248
- detail: 'for...of loop',
249
- documentation: 'Iterate over items in Ripple template',
250
- insertText: 'for (const ${1:item} of ${2:items}) {\n\t<${3:li}>{${1:item}}</${3:li}>\n}',
251
- insertTextFormat: InsertTextFormat.Snippet,
252
- sortText: '0-for-of',
253
- },
254
- {
255
- label: 'for-index',
256
- kind: CompletionItemKind.Snippet,
257
- detail: 'for...of loop with index',
258
- documentation: 'Iterate with index',
259
- insertText:
260
- 'for (const ${1:item} of ${2:items}; index ${3:i}) {\n\t<${4:li}>{${1:item}}{" at "}{${3:i}}</${4:li}>\n}',
261
- insertTextFormat: InsertTextFormat.Snippet,
262
- sortText: '0-for-index',
263
- },
264
- {
265
- label: 'for-key',
266
- kind: CompletionItemKind.Snippet,
267
- detail: 'for...of loop with key',
268
- documentation: 'Iterate with key for identity',
269
- insertText:
270
- 'for (const ${1:item} of ${2:items}; key ${1:item}.${3:id}) {\n\t<${4:li}>{${1:item}.${5:text}}</${4:li}>\n}',
271
- insertTextFormat: InsertTextFormat.Snippet,
272
- sortText: '0-for-key',
273
- },
274
- {
275
- label: 'for-index-key',
276
- kind: CompletionItemKind.Snippet,
277
- detail: 'for...of loop with key',
278
- documentation: 'Iterate with key for identity',
279
- insertText:
280
- "for (const ${1:item} of ${2:items}; index ${3:i}; key ${1:item}.${4:id}) {\n\t<${5:li}>{${1:item}.${6:text}}{' at index '}{${3}}</${5:li}>\n}",
281
- insertTextFormat: InsertTextFormat.Snippet,
282
- sortText: '0-for-key-index',
283
- },
284
- {
285
- label: 'if-else',
286
- kind: CompletionItemKind.Snippet,
287
- detail: 'if...else statement',
288
- documentation: 'Conditional rendering',
289
- insertText: 'if (${1:condition}) {\n\t$2\n} else {\n\t$3\n}',
290
- insertTextFormat: InsertTextFormat.Snippet,
291
- sortText: '0-if-else',
292
- },
293
- {
294
- label: 'switch-case',
295
- kind: CompletionItemKind.Snippet,
296
- detail: 'switch statement',
297
- documentation: 'Switch-based conditional rendering',
298
- insertText:
299
- "switch (${1:value}) {\n\tcase ${2:'case1'}:\n\t\t$3\n\t\tbreak;\n\tcase ${4:'case2'}:\n\t\t$5\n\t\tbreak;\n\tdefault:\n\t\t$6\n}",
300
- insertTextFormat: InsertTextFormat.Snippet,
301
- sortText: '0-switch-case',
302
- },
303
- {
304
- label: 'untrack',
305
- kind: CompletionItemKind.Snippet,
306
- detail: 'Untrack reactive value',
307
- documentation: 'Read reactive value without creating dependency',
308
- insertText: 'untrack(() => @${1:value})',
309
- insertTextFormat: InsertTextFormat.Snippet,
310
- sortText: '0-untrack',
311
- },
312
- {
313
- label: 'try-pending',
314
- kind: CompletionItemKind.Snippet,
315
- detail: 'try...pending block',
316
- documentation: 'Handle async content with loading fallback',
317
- insertText: "try {\n\t$1\n} pending {\n\t<div>{'Loading...'}</div>\n}",
318
- insertTextFormat: InsertTextFormat.Snippet,
319
- sortText: '0-try-pending',
320
- },
321
- ];
322
-
323
- /**
324
- * Import suggestions for Ripple
325
- */
326
- const RIPPLE_IMPORTS = [
327
- {
328
- label: 'import track',
329
- kind: CompletionItemKind.Snippet,
330
- detail: 'Import track from ripple',
331
- insertText: "import { track } from 'ripple';",
332
- insertTextFormat: InsertTextFormat.Snippet,
333
- sortText: '0-import-track',
334
- },
335
- {
336
- label: 'import effect',
337
- kind: CompletionItemKind.Snippet,
338
- detail: 'Import effect from ripple',
339
- insertText: "import { effect } from 'ripple';",
340
- insertTextFormat: InsertTextFormat.Snippet,
341
- sortText: '0-import-effect',
342
- },
343
- {
344
- label: 'import untrack',
345
- kind: CompletionItemKind.Snippet,
346
- detail: 'Import untrack from ripple',
347
- insertText: "import { untrack } from 'ripple';",
348
- insertTextFormat: InsertTextFormat.Snippet,
349
- sortText: '0-import-untrack',
350
- },
351
- // {
352
- // label: 'import ripple-types',
353
- // kind: CompletionItemKind.Snippet,
354
- // detail: 'Import Ripple types',
355
- // insertText: "import type { Tracked, PropsWithChildren, Component } from 'ripple';",
356
- // insertTextFormat: InsertTextFormat.Snippet,
357
- // sortText: '0-import-types',
358
- // },
359
- ];
360
-
361
- /**
362
- * @returns {LanguageServicePlugin}
363
- */
364
- export function createCompletionPlugin() {
365
- return {
366
- name: 'ripple-completion-enhancer',
367
- capabilities: {
368
- completionProvider: {
369
- // Trigger on Ripple-specific syntax:
370
- // '<' - JSX/HTML tags
371
- // '#' - #style, #server keywords
372
- triggerCharacters: ['<', '#'],
373
- resolveProvider: false,
374
- },
375
- },
376
- // leaving context for future use
377
- create(context) {
378
- return {
379
- // Mark this as providing additional completions, not replacing existing ones
380
- // This ensures TypeScript/JavaScript completions are still shown alongside Ripple snippets
381
- isAdditionalCompletion: true,
382
- async provideCompletionItems(document, position, completionContext, _token) {
383
- if (!is_ripple_document(document.uri)) {
384
- return { items: [], isIncomplete: false };
385
- }
386
-
387
- const { virtualCode } = getVirtualCode(document, context);
388
-
389
- if (virtualCode && virtualCode.languageId !== 'ripple') {
390
- // Check if we're inside an embedded code (like CSS in <style> blocks)
391
- // If so, don't provide Ripple snippets - let CSS completions take priority
392
- log(`Skipping Ripple completions in the '${virtualCode.languageId}' context`);
393
- return { items: [], isIncomplete: false };
394
- }
395
-
396
- const line = document.getText({
397
- start: { line: position.line, character: 0 },
398
- end: position,
399
- });
400
-
401
- /** @type {CompletionItem[]} */
402
- const items = [];
403
-
404
- // Debug: log trigger info with clear marker
405
- // triggerKind: 1 = Invoked (Ctrl+Space), 2 = TriggerCharacter, 3 = TriggerForIncompleteCompletions
406
- log('🔔 Completion triggered:', {
407
- triggerKind: completionContext.triggerKind,
408
- triggerKindName:
409
- completionContext.triggerKind === 1
410
- ? 'Invoked'
411
- : completionContext.triggerKind === 2
412
- ? 'TriggerCharacter'
413
- : completionContext.triggerKind === 3
414
- ? 'Incomplete'
415
- : 'Unknown',
416
- triggerCharacter: completionContext.triggerCharacter || '(none)',
417
- position: `${position.line}:${position.character}`,
418
- lineEnd: line.substring(Math.max(0, line.length - 30)),
419
- });
420
-
421
- const fullText = document.getText();
422
- const cursorOffset = document.offsetAt(position);
423
-
424
- if (isInsideImport(fullText, cursorOffset)) {
425
- items.push(...RIPPLE_IMPORTS);
426
- return { items, isIncomplete: false };
427
- } else if (isInsideExport(fullText, cursorOffset)) {
428
- return { items, isIncomplete: false };
429
- }
430
-
431
- // @ accessor hint when typing after @
432
- if (/@\w*$/.test(line)) {
433
- items.push({
434
- label: '@value',
435
- kind: CompletionItemKind.Variable,
436
- detail: 'Access tracked value',
437
- documentation: 'Use @ to read/write tracked values',
438
- });
439
- }
440
-
441
- // RippleMap/RippleSet completions when typing R, M...
442
- // Also detects if 'new' is already typed before it to avoid duplicating
443
- const trackedMatch = line.match(/(new\s+)?[R,M]([\w\.]*)$/);
444
-
445
- if (trackedMatch) {
446
- const hasNew = !!trackedMatch[1];
447
- const typed = trackedMatch[2].toLowerCase();
448
-
449
- for (const snippet of TRACKED_COLLECTION_SNIPPETS) {
450
- // Match if typing matches start of 'rackedMap', 'rackedSet' (after T)
451
- const afterT = snippet.label.slice(1).toLowerCase(); // 'rackedmap' or 'rackedset'
452
- if (typed === '' || afterT.startsWith(typed)) {
453
- // Determine insert text - skip 'new ' if already present
454
- const insertText = hasNew
455
- ? `${snippet.label}(\${1})`
456
- : `new ${snippet.label}(\${1})`;
457
-
458
- items.push({
459
- label: snippet.label,
460
- filterText: snippet.filterText,
461
- kind: CompletionItemKind.Snippet,
462
- detail: snippet.detail,
463
- documentation: snippet.documentation,
464
- insertText: insertText,
465
- insertTextFormat: InsertTextFormat.Snippet,
466
- sortText: '0-' + snippet.label.toLowerCase(),
467
- // Replace 'T...' or 'new T...' depending on what was typed
468
- textEdit: {
469
- range: {
470
- start: {
471
- line: position.line,
472
- character: position.character - trackedMatch[0].length,
473
- },
474
- end: position,
475
- },
476
- newText: insertText,
477
- },
478
- additionalTextEdits: snippet
479
- ? snippet.importName != null
480
- ? generateImportEdit(fullText, snippet.importName)
481
- : undefined
482
- : undefined,
483
- });
484
- }
485
- }
486
- }
487
-
488
- // Ripple keywords - extract the last word being typed
489
- const wordMatch = line.match(/(\w+)$/);
490
- const currentWord = wordMatch ? wordMatch[1] : '';
491
-
492
- // Debug: show what word we're matching
493
- log('Current word:', currentWord, 'length:', currentWord.length);
494
-
495
- // ALWAYS provide Ripple snippets and keywords
496
- // Even with 1 character, we return items so that when combined with TypeScript completions,
497
- // the merged result will include our items. VS Code's fuzzy matching will filter them.
498
- items.push(...RIPPLE_SNIPPETS);
499
-
500
- // Return isIncomplete=false and let VS Code handle filtering
501
- // Since we're providing all items every time, VS Code can cache and filter client-side
502
- // This works because our items have proper labels that match VS Code's fuzzy matching
503
- return { items, isIncomplete: currentWord.length < 2 };
504
- },
505
- };
506
- },
507
- };
508
- }