@zenithbuild/language-server 0.2.2

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