@zenithbuild/language-server 0.6.1 → 0.6.17

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/src/server.ts DELETED
@@ -1,937 +0,0 @@
1
- /**
2
- * Zenith Language Server
3
- *
4
- * Provides full IntelliSense for Zenith .zen files.
5
- *
6
- * Architecture Principles:
7
- * - Compiler is the source of truth
8
- * - No runtime assumptions
9
- * - Static analysis only
10
- * - Graceful degradation for missing plugins
11
- */
12
-
13
- import {
14
- createConnection,
15
- TextDocuments,
16
- ProposedFeatures,
17
- InitializeParams,
18
- DidChangeConfigurationNotification,
19
- CompletionItem,
20
- CompletionItemKind,
21
- TextDocumentPositionParams,
22
- CodeActionParams,
23
- CodeAction,
24
- TextDocumentSyncKind,
25
- InitializeResult,
26
- Hover,
27
- MarkupKind,
28
- InsertTextFormat
29
- } from 'vscode-languageserver/node';
30
-
31
- import { TextDocument } from 'vscode-languageserver-textdocument';
32
- import * as path from 'path';
33
-
34
- import {
35
- detectProjectRoot,
36
- buildProjectGraph,
37
- resolveComponent,
38
- ProjectGraph
39
- } from './project';
40
-
41
- import {
42
- DIRECTIVES,
43
- isDirective,
44
- getDirective,
45
- getDirectiveNames,
46
- canPlaceDirective,
47
- parseForExpression
48
- } from './metadata/directive-metadata';
49
-
50
- import {
51
- parseZenithImports,
52
- hasRouterImport,
53
- resolveModule,
54
- resolveExport,
55
- getAllModules,
56
- getModuleExports
57
- } from './imports';
58
-
59
- import {
60
- ROUTER_HOOKS,
61
- ZENLINK_PROPS,
62
- ROUTE_FIELDS,
63
- getRouterHook,
64
- isRouterHook,
65
- getZenLinkPropNames
66
- } from './router';
67
-
68
- import { collectDiagnostics } from './diagnostics';
69
- import { buildEventBindingCodeActions, buildDomLintCodeActions, buildWindowDocumentCodeActions } from './code-actions';
70
- import { DEFAULT_SETTINGS, normalizeSettings, ZenithServerSettings } from './settings';
71
-
72
- // Create connection and document manager
73
- const connection = createConnection(ProposedFeatures.all);
74
- const documents = new TextDocuments(TextDocument);
75
-
76
- // Project graph cache
77
- let projectGraphs: Map<string, ProjectGraph> = new Map();
78
- let workspaceFolders: string[] = [];
79
- let globalSettings: ZenithServerSettings = DEFAULT_SETTINGS;
80
-
81
- // Lifecycle hooks and platform primitives with documentation
82
- const LIFECYCLE_HOOKS = [
83
- { name: 'state', doc: 'Declare a reactive state variable', snippet: 'state ${1:name} = ${2:value}', kind: CompletionItemKind.Keyword },
84
- { name: 'zenMount', doc: 'Mount callback with ctx.cleanup for disposers', snippet: 'zenMount((ctx) => {\n\t$0\n})', kind: CompletionItemKind.Function },
85
- { name: 'zenOnMount', doc: 'Called when component is mounted to the DOM', snippet: 'zenOnMount(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
86
- { name: 'zenOnDestroy', doc: 'Called when component is removed from the DOM', snippet: 'zenOnDestroy(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
87
- { name: 'zenOnUpdate', doc: 'Called after any state update causes a re-render', snippet: 'zenOnUpdate(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
88
- { name: 'zenEffect', doc: 'Reactive effect that re-runs when dependencies change', snippet: 'zenEffect(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
89
- { name: 'useFetch', doc: 'Fetch data with caching and SSG support', snippet: 'useFetch("${1:url}")', kind: CompletionItemKind.Function }
90
- ];
91
-
92
- const PLATFORM_PRIMITIVES = [
93
- { name: 'zenWindow', doc: 'SSR-safe window access (returns null when not in browser)', snippet: 'zenWindow()', kind: CompletionItemKind.Function },
94
- { name: 'zenDocument', doc: 'SSR-safe document access (returns null when not in browser)', snippet: 'zenDocument()', kind: CompletionItemKind.Function },
95
- { name: 'zenOn', doc: 'Event subscription with disposer; register via ctx.cleanup', snippet: 'zenOn(${1:target}, \'${2:event}\', ${3:handler})', kind: CompletionItemKind.Function },
96
- { name: 'zenResize', doc: 'Window resize handler; returns disposer for ctx.cleanup', snippet: 'zenResize(({ w, h }) => {\n\t$0\n})', kind: CompletionItemKind.Function },
97
- { name: 'collectRefs', doc: 'Collect multiple refs into a deterministic node list', snippet: 'collectRefs(${1:refA}, ${2:refB})', kind: CompletionItemKind.Function },
98
- { name: 'signal', doc: 'Create a signal for explicit get/set', snippet: 'signal(${1:0})', kind: CompletionItemKind.Function },
99
- { name: 'ref', doc: 'Create a ref for DOM node or value', snippet: 'ref<${1:HTMLElement}>()', kind: CompletionItemKind.Function }
100
- ];
101
-
102
- // Common HTML elements
103
- const HTML_ELEMENTS = [
104
- { tag: 'div', doc: 'Generic container element' },
105
- { tag: 'span', doc: 'Inline container element' },
106
- { tag: 'p', doc: 'Paragraph element' },
107
- { tag: 'a', doc: 'Anchor/link element', attrs: 'href="$1"' },
108
- { tag: 'button', doc: 'Button element', attrs: 'on:click={$1}' },
109
- { tag: 'input', doc: 'Input element', attrs: 'type="$1"', selfClosing: true },
110
- { tag: 'img', doc: 'Image element', attrs: 'src="$1" alt="$2"', selfClosing: true },
111
- { tag: 'h1', doc: 'Heading level 1' },
112
- { tag: 'h2', doc: 'Heading level 2' },
113
- { tag: 'h3', doc: 'Heading level 3' },
114
- { tag: 'h4', doc: 'Heading level 4' },
115
- { tag: 'h5', doc: 'Heading level 5' },
116
- { tag: 'h6', doc: 'Heading level 6' },
117
- { tag: 'ul', doc: 'Unordered list' },
118
- { tag: 'ol', doc: 'Ordered list' },
119
- { tag: 'li', doc: 'List item' },
120
- { tag: 'nav', doc: 'Navigation section' },
121
- { tag: 'header', doc: 'Header section' },
122
- { tag: 'footer', doc: 'Footer section' },
123
- { tag: 'main', doc: 'Main content' },
124
- { tag: 'section', doc: 'Generic section' },
125
- { tag: 'article', doc: 'Article content' },
126
- { tag: 'aside', doc: 'Sidebar content' },
127
- { tag: 'form', doc: 'Form element' },
128
- { tag: 'label', doc: 'Form label', attrs: 'for="$1"' },
129
- { tag: 'select', doc: 'Dropdown select' },
130
- { tag: 'option', doc: 'Select option', attrs: 'value="$1"' },
131
- { tag: 'textarea', doc: 'Multi-line text input' },
132
- { tag: 'table', doc: 'Table element' },
133
- { tag: 'thead', doc: 'Table header group' },
134
- { tag: 'tbody', doc: 'Table body group' },
135
- { tag: 'tr', doc: 'Table row' },
136
- { tag: 'th', doc: 'Table header cell' },
137
- { tag: 'td', doc: 'Table data cell' },
138
- { tag: 'br', doc: 'Line break', selfClosing: true },
139
- { tag: 'hr', doc: 'Horizontal rule', selfClosing: true },
140
- { tag: 'strong', doc: 'Strong emphasis (bold)' },
141
- { tag: 'em', doc: 'Emphasis (italic)' },
142
- { tag: 'code', doc: 'Inline code' },
143
- { tag: 'pre', doc: 'Preformatted text' },
144
- { tag: 'blockquote', doc: 'Block quotation' },
145
- { tag: 'slot', doc: 'Zenith slot for child content' }
146
- ];
147
-
148
- // Common HTML attributes
149
- const HTML_ATTRIBUTES = [
150
- 'id', 'class', 'style', 'title', 'href', 'src', 'alt', 'type', 'name', 'value',
151
- 'placeholder', 'disabled', 'checked', 'readonly', 'required', 'hidden'
152
- ];
153
-
154
- // DOM events for on:event handlers
155
- const DOM_EVENTS = [
156
- 'click', 'change', 'input', 'submit', 'keydown', 'keyup', 'keypress',
157
- 'focus', 'blur', 'mouseover', 'mouseout', 'mouseenter', 'mouseleave'
158
- ];
159
-
160
- // State analysis
161
- function extractStates(script: string): Map<string, string> {
162
- const states = new Map<string, string>();
163
- const statePattern = /state\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*([^;\n]+)/g;
164
- let match;
165
-
166
- while ((match = statePattern.exec(script)) !== null) {
167
- if (match[1] && match[2]) {
168
- states.set(match[1], match[2].trim());
169
- }
170
- }
171
-
172
- return states;
173
- }
174
-
175
- // Extract functions from script
176
- function extractFunctions(script: string): Array<{ name: string, params: string, isAsync: boolean }> {
177
- const functions: Array<{ name: string, params: string, isAsync: boolean }> = [];
178
- const funcPattern = /(async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(([^)]*)\)/g;
179
- let match;
180
-
181
- while ((match = funcPattern.exec(script)) !== null) {
182
- if (match[2]) {
183
- functions.push({
184
- name: match[2],
185
- params: match[3] || '',
186
- isAsync: !!match[1]
187
- });
188
- }
189
- }
190
-
191
- // Arrow functions assigned to const/let
192
- const arrowPattern = /(?:const|let)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(async\s+)?\([^)]*\)\s*=>/g;
193
- while ((match = arrowPattern.exec(script)) !== null) {
194
- if (match[1]) {
195
- functions.push({
196
- name: match[1],
197
- params: '',
198
- isAsync: !!match[2]
199
- });
200
- }
201
- }
202
-
203
- return functions;
204
- }
205
-
206
- // Extract loop variables from zen:for directives
207
- function extractLoopVariables(text: string): string[] {
208
- const vars: string[] = [];
209
- const loopPattern = /zen:for\s*=\s*["']([^"']+)["']/g;
210
- let match;
211
-
212
- while ((match = loopPattern.exec(text)) !== null) {
213
- const parsed = parseForExpression(match[1]);
214
- if (parsed) {
215
- vars.push(parsed.itemVar);
216
- if (parsed.indexVar) vars.push(parsed.indexVar);
217
- }
218
- }
219
-
220
- return vars;
221
- }
222
-
223
- // Get script content from document
224
- function getScriptContent(text: string): string {
225
- const match = text.match(/<script[^>]*>([\s\S]*?)<\/script>/i);
226
- return match ? match[1] : '';
227
- }
228
-
229
- // Check position context
230
- function getPositionContext(text: string, offset: number): {
231
- inScript: boolean;
232
- inStyle: boolean;
233
- inTag: boolean;
234
- inExpression: boolean;
235
- inTemplate: boolean;
236
- inAttributeValue: boolean;
237
- tagName: string | null;
238
- currentWord: string;
239
- afterAt: boolean;
240
- afterColon: boolean;
241
- } {
242
- const before = text.substring(0, offset);
243
-
244
- const scriptOpens = (before.match(/<script[^>]*>/gi) || []).length;
245
- const scriptCloses = (before.match(/<\/script>/gi) || []).length;
246
- const inScript = scriptOpens > scriptCloses;
247
-
248
- const styleOpens = (before.match(/<style[^>]*>/gi) || []).length;
249
- const styleCloses = (before.match(/<\/style>/gi) || []).length;
250
- const inStyle = styleOpens > styleCloses;
251
-
252
- const lastTagOpen = before.lastIndexOf('<');
253
- const lastTagClose = before.lastIndexOf('>');
254
- const inTag = lastTagOpen > lastTagClose;
255
-
256
- const lastBraceOpen = before.lastIndexOf('{');
257
- const lastBraceClose = before.lastIndexOf('}');
258
- const inExpression = lastBraceOpen > lastBraceClose && !inScript && !inStyle;
259
-
260
- const inTemplate = !inScript && !inStyle;
261
-
262
- // Check if inside attribute value
263
- const afterLastTag = before.substring(lastTagOpen);
264
- const quoteMatch = afterLastTag.match(/=["'][^"']*$/);
265
- const inAttributeValue = inTag && !!quoteMatch;
266
-
267
- let tagName: string | null = null;
268
- if (inTag) {
269
- const tagMatch = before.substring(lastTagOpen).match(/<\/?([A-Za-z][A-Za-z0-9-]*)/);
270
- if (tagMatch) {
271
- tagName = tagMatch[1];
272
- }
273
- }
274
-
275
- // Get current word being typed
276
- const wordMatch = before.match(/[a-zA-Z_$:@][a-zA-Z0-9_$:-]*$/);
277
- const currentWord = wordMatch ? wordMatch[0] : '';
278
-
279
- // Check for @ or : prefix for event/binding completion
280
- const afterAt = before.endsWith('@') || currentWord.startsWith('@');
281
- const afterColon = before.endsWith(':') || (currentWord.startsWith(':') && !currentWord.startsWith(':'));
282
-
283
- return { inScript, inStyle, inTag, inExpression, inTemplate, inAttributeValue, tagName, currentWord, afterAt, afterColon };
284
- }
285
-
286
- // Get project graph for a document
287
- function getProjectGraph(docUri: string): ProjectGraph | null {
288
- const filePath = docUri.replace('file://', '');
289
- const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
290
-
291
- if (!projectRoot) {
292
- return null;
293
- }
294
-
295
- if (!projectGraphs.has(projectRoot)) {
296
- projectGraphs.set(projectRoot, buildProjectGraph(projectRoot));
297
- }
298
-
299
- return projectGraphs.get(projectRoot) || null;
300
- }
301
-
302
- // Invalidate project graph on file changes
303
- function invalidateProjectGraph(uri: string) {
304
- const filePath = uri.replace('file://', '');
305
- const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
306
- if (projectRoot) {
307
- projectGraphs.delete(projectRoot);
308
- }
309
- }
310
-
311
- connection.onInitialize((params: InitializeParams): InitializeResult => {
312
- workspaceFolders = (params.workspaceFolders || [])
313
- .map((folder) => folder.uri.replace('file://', ''));
314
- if (workspaceFolders.length === 0 && params.rootUri) {
315
- workspaceFolders = [params.rootUri.replace('file://', '')];
316
- }
317
-
318
- return {
319
- capabilities: {
320
- textDocumentSync: TextDocumentSyncKind.Incremental,
321
- completionProvider: {
322
- resolveProvider: true,
323
- triggerCharacters: ['{', '<', '"', "'", '=', '.', ' ', ':', '(', '@']
324
- },
325
- hoverProvider: true,
326
- codeActionProvider: true
327
- }
328
- };
329
- });
330
-
331
- connection.onInitialized(() => {
332
- connection.client.register(DidChangeConfigurationNotification.type);
333
- });
334
-
335
- connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] => {
336
- const document = documents.get(params.textDocument.uri);
337
- if (!document) return [];
338
-
339
- const text = document.getText();
340
- const offset = document.offsetAt(params.position);
341
- const ctx = getPositionContext(text, offset);
342
- const completions: CompletionItem[] = [];
343
-
344
- const graph = getProjectGraph(params.textDocument.uri);
345
- const script = getScriptContent(text);
346
- const states = extractStates(script);
347
- const functions = extractFunctions(script);
348
- const imports = parseZenithImports(script);
349
- const routerEnabled = hasRouterImport(imports);
350
- const loopVariables = extractLoopVariables(text);
351
-
352
- // Get line content before cursor
353
- const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
354
- const lineBefore = text.substring(lineStart, offset);
355
-
356
- // === SCRIPT CONTEXT ===
357
- if (ctx.inScript) {
358
- // Lifecycle hooks and state
359
- for (const hook of LIFECYCLE_HOOKS) {
360
- if (!ctx.currentWord || hook.name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
361
- completions.push({
362
- label: hook.name,
363
- kind: hook.kind,
364
- detail: hook.name === 'state' ? 'Zenith State' : 'Zenith Lifecycle',
365
- documentation: { kind: MarkupKind.Markdown, value: hook.doc },
366
- insertText: hook.snippet,
367
- insertTextFormat: InsertTextFormat.Snippet,
368
- sortText: `0_${hook.name}`,
369
- preselect: hook.name === 'state' && ctx.currentWord.startsWith('s')
370
- });
371
- }
372
- }
373
-
374
- // Platform primitives (zenWindow, zenDocument, zenOn, zenResize, collectRefs, signal, ref)
375
- for (const prim of PLATFORM_PRIMITIVES) {
376
- if (!ctx.currentWord || prim.name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
377
- completions.push({
378
- label: prim.name,
379
- kind: prim.kind,
380
- detail: 'Zenith Platform',
381
- documentation: { kind: MarkupKind.Markdown, value: prim.doc },
382
- insertText: prim.snippet,
383
- insertTextFormat: InsertTextFormat.Snippet,
384
- sortText: `0_${prim.name}`
385
- });
386
- }
387
- }
388
-
389
- // Soft suggestions: window -> zenWindow, document -> zenDocument (do not block normal JS)
390
- const lc = ctx.currentWord.toLowerCase();
391
- if (lc === 'window' || lc.startsWith('wind')) {
392
- completions.push({
393
- label: 'zenWindow',
394
- kind: CompletionItemKind.Function,
395
- detail: 'Zenith (SSR-safe)',
396
- documentation: { kind: MarkupKind.Markdown, value: 'Use zenWindow() instead of window for SSR-safe access.' },
397
- insertText: 'zenWindow()',
398
- sortText: '0_zenWindow'
399
- });
400
- }
401
- if (lc === 'document' || lc.startsWith('doc')) {
402
- completions.push({
403
- label: 'zenDocument',
404
- kind: CompletionItemKind.Function,
405
- detail: 'Zenith (SSR-safe)',
406
- documentation: { kind: MarkupKind.Markdown, value: 'Use zenDocument() instead of document for SSR-safe access.' },
407
- insertText: 'zenDocument()',
408
- sortText: '0_zenDocument'
409
- });
410
- }
411
-
412
- // Router hooks when router is imported
413
- if (routerEnabled) {
414
- for (const hook of Object.values(ROUTER_HOOKS)) {
415
- if (!ctx.currentWord || hook.name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
416
- completions.push({
417
- label: hook.name,
418
- kind: CompletionItemKind.Function,
419
- detail: hook.owner,
420
- documentation: { kind: MarkupKind.Markdown, value: `${hook.description}\n\n**Returns:** \`${hook.returns}\`` },
421
- insertText: `${hook.name}()`,
422
- sortText: `0_${hook.name}`
423
- });
424
- }
425
- }
426
- }
427
-
428
- // Declared functions
429
- for (const func of functions) {
430
- if (!ctx.currentWord || func.name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
431
- completions.push({
432
- label: func.name,
433
- kind: CompletionItemKind.Function,
434
- detail: `${func.isAsync ? 'async ' : ''}function ${func.name}(${func.params})`,
435
- insertText: `${func.name}($0)`,
436
- insertTextFormat: InsertTextFormat.Snippet
437
- });
438
- }
439
- }
440
-
441
- // State variables
442
- for (const [name, value] of states) {
443
- if (!ctx.currentWord || name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
444
- completions.push({
445
- label: name,
446
- kind: CompletionItemKind.Variable,
447
- detail: `state ${name}`,
448
- documentation: `Current value: ${value}`
449
- });
450
- }
451
- }
452
-
453
- // Import path completions
454
- const isImportPath = /from\s+['"][^'"]*$/.test(lineBefore) || /import\s+['"][^'"]*$/.test(lineBefore);
455
- if (isImportPath) {
456
- for (const mod of getAllModules()) {
457
- completions.push({
458
- label: mod.module,
459
- kind: CompletionItemKind.Module,
460
- detail: mod.kind === 'plugin' ? 'Zenith Plugin' : 'Zenith Core',
461
- documentation: mod.description,
462
- insertText: mod.module
463
- });
464
- }
465
- }
466
- }
467
-
468
- // === EXPRESSION CONTEXT { } ===
469
- if (ctx.inExpression) {
470
- // State variables
471
- for (const [name, value] of states) {
472
- completions.push({
473
- label: name,
474
- kind: CompletionItemKind.Variable,
475
- detail: `state ${name}`,
476
- documentation: `Value: ${value}`,
477
- sortText: `0_${name}`
478
- });
479
- }
480
-
481
- // Functions
482
- for (const func of functions) {
483
- completions.push({
484
- label: func.name,
485
- kind: CompletionItemKind.Function,
486
- detail: `${func.isAsync ? 'async ' : ''}function`,
487
- insertText: `${func.name}()`,
488
- sortText: `1_${func.name}`
489
- });
490
- }
491
-
492
- // Loop variables
493
- for (const loopVar of loopVariables) {
494
- completions.push({
495
- label: loopVar,
496
- kind: CompletionItemKind.Variable,
497
- detail: 'loop variable',
498
- sortText: `0_${loopVar}`
499
- });
500
- }
501
-
502
- // Route fields when router is imported
503
- if (routerEnabled) {
504
- for (const field of ROUTE_FIELDS) {
505
- completions.push({
506
- label: `route.${field.name}`,
507
- kind: CompletionItemKind.Property,
508
- detail: field.type,
509
- documentation: field.description,
510
- sortText: `2_route_${field.name}`
511
- });
512
- }
513
- }
514
- }
515
-
516
- // === TEMPLATE CONTEXT (not in script/style) ===
517
- if (ctx.inTemplate && !ctx.inExpression && !ctx.inAttributeValue) {
518
- const isAfterOpenBracket = lineBefore.match(/<\s*$/);
519
- const isTypingTag = ctx.currentWord.length > 0 && !ctx.inTag;
520
-
521
- // Components and layouts (PascalCase)
522
- if (graph && (isAfterOpenBracket || (isTypingTag && /^[A-Z]/.test(ctx.currentWord)))) {
523
- for (const [name, info] of graph.layouts) {
524
- if (!ctx.currentWord || name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
525
- const propStr = info.props.length > 0 ? ` ${info.props[0]}="$1"` : '';
526
- completions.push({
527
- label: name,
528
- kind: CompletionItemKind.Class,
529
- detail: `layout`,
530
- documentation: { kind: MarkupKind.Markdown, value: `**Layout** from \`${path.basename(info.filePath)}\`\n\nProps: ${info.props.join(', ') || 'none'}` },
531
- insertText: isAfterOpenBracket
532
- ? `${name}${propStr}>$0</${name}>`
533
- : `<${name}${propStr}>$0</${name}>`,
534
- insertTextFormat: InsertTextFormat.Snippet,
535
- sortText: `0_${name}`
536
- });
537
- }
538
- }
539
-
540
- for (const [name, info] of graph.components) {
541
- if (!ctx.currentWord || name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
542
- completions.push({
543
- label: name,
544
- kind: CompletionItemKind.Class,
545
- detail: `component`,
546
- documentation: { kind: MarkupKind.Markdown, value: `**Component** from \`${path.basename(info.filePath)}\`\n\nProps: ${info.props.join(', ') || 'none'}` },
547
- insertText: isAfterOpenBracket
548
- ? `${name} $0/>`
549
- : `<${name} $0/>`,
550
- insertTextFormat: InsertTextFormat.Snippet,
551
- sortText: `0_${name}`
552
- });
553
- }
554
- }
555
- }
556
-
557
- // ZenLink when router is imported
558
- if (routerEnabled && (isAfterOpenBracket || (isTypingTag && ctx.currentWord.toLowerCase().startsWith('z')))) {
559
- completions.push({
560
- label: 'ZenLink',
561
- kind: CompletionItemKind.Class,
562
- detail: 'router component',
563
- documentation: { kind: MarkupKind.Markdown, value: '**Router Component** (zenith/router)\n\nDeclarative navigation component for routes.\n\n**Props:** to, preload, replace, class, activeClass' },
564
- insertText: isAfterOpenBracket ? 'ZenLink to="$1">$0</ZenLink>' : '<ZenLink to="$1">$0</ZenLink>',
565
- insertTextFormat: InsertTextFormat.Snippet,
566
- sortText: '0_ZenLink'
567
- });
568
- }
569
-
570
- // HTML elements (lowercase)
571
- if (isAfterOpenBracket || (isTypingTag && /^[a-z]/.test(ctx.currentWord))) {
572
- for (const el of HTML_ELEMENTS) {
573
- if (!ctx.currentWord || el.tag.startsWith(ctx.currentWord.toLowerCase())) {
574
- let snippet: string;
575
- if (el.selfClosing) {
576
- snippet = el.attrs ? `${el.tag} ${el.attrs} />` : `${el.tag} />`;
577
- } else {
578
- snippet = el.attrs ? `${el.tag} ${el.attrs}>$0</${el.tag}>` : `${el.tag}>$0</${el.tag}>`;
579
- }
580
-
581
- completions.push({
582
- label: el.tag,
583
- kind: CompletionItemKind.Property,
584
- detail: 'HTML',
585
- documentation: el.doc,
586
- insertText: isAfterOpenBracket ? snippet : `<${snippet}>`,
587
- insertTextFormat: InsertTextFormat.Snippet,
588
- sortText: `1_${el.tag}`
589
- });
590
- }
591
- }
592
- }
593
- }
594
-
595
- // === INSIDE TAG (attributes) ===
596
- if (ctx.inTag && ctx.tagName && !ctx.inAttributeValue) {
597
- // Directives (zen:if, zen:for, etc.)
598
- const elementType = ctx.tagName === 'slot' ? 'slot' : (/^[A-Z]/.test(ctx.tagName) ? 'component' : 'element');
599
-
600
- for (const directiveName of getDirectiveNames()) {
601
- if (canPlaceDirective(directiveName, elementType as 'element' | 'component' | 'slot')) {
602
- if (!ctx.currentWord || directiveName.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
603
- const directive = getDirective(directiveName);
604
- if (directive) {
605
- completions.push({
606
- label: directive.name,
607
- kind: CompletionItemKind.Keyword,
608
- detail: directive.category,
609
- documentation: { kind: MarkupKind.Markdown, value: `${directive.description}\n\n**Syntax:** \`${directive.syntax}\`` },
610
- insertText: `${directive.name}="$1"`,
611
- insertTextFormat: InsertTextFormat.Snippet,
612
- sortText: `0_${directive.name}`
613
- });
614
- }
615
- }
616
- }
617
- }
618
-
619
- // on:event completions
620
- if (!ctx.currentWord || ctx.currentWord.startsWith('on:') || ctx.currentWord === 'on') {
621
- for (const event of DOM_EVENTS) {
622
- completions.push({
623
- label: `on:${event}`,
624
- kind: CompletionItemKind.Event,
625
- detail: 'event binding',
626
- documentation: `Bind to ${event} event`,
627
- insertText: `on:${event}={$1}`,
628
- insertTextFormat: InsertTextFormat.Snippet,
629
- sortText: `1_on:${event}`
630
- });
631
- }
632
- }
633
-
634
- // :prop reactive bindings
635
- if (ctx.afterColon || ctx.currentWord.startsWith(':')) {
636
- for (const attr of HTML_ATTRIBUTES) {
637
- completions.push({
638
- label: `:${attr}`,
639
- kind: CompletionItemKind.Property,
640
- detail: 'reactive binding',
641
- documentation: `Reactive binding for ${attr}`,
642
- insertText: `:${attr}="$1"`,
643
- insertTextFormat: InsertTextFormat.Snippet,
644
- sortText: `1_:${attr}`
645
- });
646
- }
647
- }
648
-
649
- // Component props
650
- if (/^[A-Z]/.test(ctx.tagName) && graph) {
651
- const component = resolveComponent(graph, ctx.tagName);
652
- if (component) {
653
- for (const prop of component.props) {
654
- completions.push({
655
- label: prop,
656
- kind: CompletionItemKind.Property,
657
- detail: `prop of <${ctx.tagName}>`,
658
- insertText: `${prop}={$1}`,
659
- insertTextFormat: InsertTextFormat.Snippet,
660
- sortText: `0_${prop}`
661
- });
662
- }
663
- }
664
- }
665
-
666
- // ZenLink props
667
- if (routerEnabled && ctx.tagName === 'ZenLink') {
668
- for (const prop of ZENLINK_PROPS) {
669
- if (!ctx.currentWord || prop.name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
670
- completions.push({
671
- label: prop.name,
672
- kind: CompletionItemKind.Property,
673
- detail: prop.required ? `${prop.type} (required)` : prop.type,
674
- documentation: prop.description,
675
- insertText: prop.name === 'to' ? `${prop.name}="$1"` : `${prop.name}`,
676
- insertTextFormat: InsertTextFormat.Snippet,
677
- sortText: prop.required ? `0_${prop.name}` : `1_${prop.name}`
678
- });
679
- }
680
- }
681
- }
682
-
683
- // HTML attributes
684
- for (const attr of HTML_ATTRIBUTES) {
685
- if (!ctx.currentWord || attr.startsWith(ctx.currentWord.toLowerCase())) {
686
- completions.push({
687
- label: attr,
688
- kind: CompletionItemKind.Property,
689
- detail: 'HTML attribute',
690
- insertText: `${attr}="$1"`,
691
- insertTextFormat: InsertTextFormat.Snippet,
692
- sortText: `3_${attr}`
693
- });
694
- }
695
- }
696
- }
697
-
698
- // === INSIDE ATTRIBUTE VALUE ===
699
- if (ctx.inAttributeValue) {
700
- // Event handler: offer functions
701
- const eventMatch = lineBefore.match(/on:[a-zA-Z][a-zA-Z0-9_-]*=["'{][^"'{}]*$/);
702
- if (eventMatch) {
703
- for (const func of functions) {
704
- completions.push({
705
- label: func.name,
706
- kind: CompletionItemKind.Function,
707
- detail: 'function',
708
- insertText: func.name
709
- });
710
- }
711
- }
712
- }
713
-
714
- return completions;
715
- });
716
-
717
- connection.onCompletionResolve((item: CompletionItem): CompletionItem => {
718
- return item;
719
- });
720
-
721
- connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
722
- const document = documents.get(params.textDocument.uri);
723
- if (!document) {
724
- return [];
725
- }
726
- const eventActions = buildEventBindingCodeActions(document, params.context.diagnostics);
727
- const domLintActions = buildDomLintCodeActions(document, params.context.diagnostics);
728
- const windowDocActions = buildWindowDocumentCodeActions(document, params.range);
729
- return [...eventActions, ...domLintActions, ...windowDocActions];
730
- });
731
-
732
- connection.onHover((params: TextDocumentPositionParams): Hover | null => {
733
- const document = documents.get(params.textDocument.uri);
734
- if (!document) return null;
735
-
736
- const text = document.getText();
737
- const offset = document.offsetAt(params.position);
738
-
739
- // Get word at position (including : and @ prefixes)
740
- const before = text.substring(0, offset);
741
- const after = text.substring(offset);
742
- const wordBefore = before.match(/[a-zA-Z0-9_$:@-]*$/)?.[0] || '';
743
- const wordAfter = after.match(/^[a-zA-Z0-9_$:-]*/)?.[0] || '';
744
- const word = wordBefore + wordAfter;
745
-
746
- if (!word) return null;
747
-
748
- // Check directives (zen:if, zen:for, etc.)
749
- if (isDirective(word)) {
750
- const directive = getDirective(word);
751
- if (directive) {
752
- let notes = '';
753
- if (directive.name === 'zen:for') {
754
- notes = '- No runtime loop\n- Compiled into static DOM instructions\n- Creates scope: `item`, `index`';
755
- } else {
756
- notes = '- Compile-time directive\n- No runtime assumptions\n- Processed at build time';
757
- }
758
-
759
- return {
760
- contents: {
761
- kind: MarkupKind.Markdown,
762
- value: `### ${directive.name}\n\n${directive.description}\n\n**Syntax:** \`${directive.syntax}\`\n\n**Notes:**\n${notes}\n\n**Example:**\n\`\`\`html\n${directive.example}\n\`\`\``
763
- }
764
- };
765
- }
766
- }
767
-
768
- // Check router hooks
769
- if (isRouterHook(word)) {
770
- const hook = getRouterHook(word);
771
- if (hook) {
772
- return {
773
- contents: {
774
- kind: MarkupKind.Markdown,
775
- value: `### ${hook.name}()\n\n**${hook.owner}**\n\n${hook.description}\n\n**Restrictions:** ${hook.restrictions}\n\n**Returns:** \`${hook.returns}\`\n\n**Signature:**\n\`\`\`typescript\n${hook.signature}\n\`\`\``
776
- }
777
- };
778
- }
779
- }
780
-
781
- // Check lifecycle hooks
782
- const hook = LIFECYCLE_HOOKS.find(h => h.name === word);
783
- if (hook) {
784
- return {
785
- contents: {
786
- kind: MarkupKind.Markdown,
787
- value: `### ${hook.name}\n\n${hook.doc}\n\n\`\`\`typescript\n${hook.snippet.replace(/\$\d/g, '').replace('$0', '// ...')}\n\`\`\``
788
- }
789
- };
790
- }
791
-
792
- // Check ZenLink
793
- if (word === 'ZenLink') {
794
- const script = getScriptContent(text);
795
- const imports = parseZenithImports(script);
796
- if (hasRouterImport(imports)) {
797
- return {
798
- contents: {
799
- kind: MarkupKind.Markdown,
800
- value: '### `<ZenLink>`\n\n**Router Component** (zenith/router)\n\nDeclarative navigation component for routes.\n\n**Props:**\n- `to` (string, required) - Route path\n- `preload` (boolean) - Prefetch on hover\n- `replace` (boolean) - Replace history entry\n- `class` (string) - CSS class\n- `activeClass` (string) - Class when active'
801
- }
802
- };
803
- }
804
- }
805
-
806
- // Check states
807
- const script = getScriptContent(text);
808
- const states = extractStates(script);
809
- if (states.has(word)) {
810
- return {
811
- contents: {
812
- kind: MarkupKind.Markdown,
813
- value: `### state \`${word}\`\n\n**Type:** inferred\n\n**Initial value:** \`${states.get(word)}\``
814
- }
815
- };
816
- }
817
-
818
- // Check functions
819
- const functions = extractFunctions(script);
820
- const func = functions.find(f => f.name === word);
821
- if (func) {
822
- return {
823
- contents: {
824
- kind: MarkupKind.Markdown,
825
- value: `### ${func.isAsync ? 'async ' : ''}function \`${func.name}\`\n\n\`\`\`typescript\n${func.isAsync ? 'async ' : ''}function ${func.name}(${func.params})\n\`\`\``
826
- }
827
- };
828
- }
829
-
830
- // Check imports
831
- const imports = parseZenithImports(script);
832
- for (const imp of imports) {
833
- if (imp.specifiers.includes(word)) {
834
- const exportMeta = resolveExport(imp.module, word);
835
- if (exportMeta) {
836
- const resolved = resolveModule(imp.module);
837
- const owner = resolved.kind === 'plugin' ? 'Plugin' : resolved.kind === 'core' ? 'Core' : 'External';
838
- return {
839
- contents: {
840
- kind: MarkupKind.Markdown,
841
- value: `### ${word}\n\n**${owner}** (${imp.module})\n\n${exportMeta.description}\n\n**Signature:**\n\`\`\`typescript\n${exportMeta.signature || word}\n\`\`\``
842
- }
843
- };
844
- }
845
- }
846
- }
847
-
848
- // Check components
849
- const graph = getProjectGraph(params.textDocument.uri);
850
- if (graph) {
851
- const component = resolveComponent(graph, word);
852
- if (component) {
853
- return {
854
- contents: {
855
- kind: MarkupKind.Markdown,
856
- value: `### ${component.type} \`<${component.name}>\`\n\n**File:** \`${component.filePath}\`\n\n**Props:** ${component.props.join(', ') || 'none'}`
857
- }
858
- };
859
- }
860
- }
861
-
862
- // Check HTML elements
863
- const htmlEl = HTML_ELEMENTS.find(e => e.tag === word);
864
- if (htmlEl) {
865
- return {
866
- contents: {
867
- kind: MarkupKind.Markdown,
868
- value: `### HTML \`<${htmlEl.tag}>\`\n\n${htmlEl.doc}`
869
- }
870
- };
871
- }
872
-
873
- return null;
874
- });
875
-
876
- // Debounce + cancellation for diagnostics (prevents editor lag from rapid typing)
877
- const DEBOUNCE_MS = 150;
878
- const validationTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
879
- const validationIds = new Map<string, number>();
880
-
881
- documents.onDidChangeContent(change => {
882
- const uri = change.document.uri;
883
- const existing = validationTimeouts.get(uri);
884
- if (existing) clearTimeout(existing);
885
- validationTimeouts.set(
886
- uri,
887
- setTimeout(() => {
888
- validationTimeouts.delete(uri);
889
- validateDocument(change.document);
890
- }, DEBOUNCE_MS)
891
- );
892
- });
893
-
894
- documents.onDidSave(event => {
895
- validateDocument(event.document);
896
- });
897
-
898
- documents.onDidOpen(event => {
899
- validateDocument(event.document);
900
- });
901
-
902
- async function validateDocument(document: TextDocument) {
903
- const uri = document.uri;
904
- const id = (validationIds.get(uri) ?? 0) + 1;
905
- validationIds.set(uri, id);
906
-
907
- const graph = getProjectGraph(uri);
908
- const filePath = uri.replace('file://', '');
909
- const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
910
- const diagnostics = await collectDiagnostics(document, graph, globalSettings, projectRoot);
911
-
912
- if (validationIds.get(uri) !== id) return;
913
- connection.sendDiagnostics({ uri, diagnostics });
914
- }
915
-
916
- connection.onDidChangeConfiguration((change) => {
917
- const config = (change.settings?.zenith ?? change.settings) as unknown;
918
- globalSettings = normalizeSettings(config);
919
-
920
- for (const doc of documents.all()) {
921
- validateDocument(doc);
922
- }
923
- });
924
-
925
- // Watch for file changes
926
- connection.onDidChangeWatchedFiles(params => {
927
- for (const change of params.changes) {
928
- invalidateProjectGraph(change.uri);
929
- }
930
-
931
- for (const doc of documents.all()) {
932
- validateDocument(doc);
933
- }
934
- });
935
-
936
- documents.listen(connection);
937
- connection.listen();