@zenithbuild/language-server 0.6.0 → 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.
@@ -1,603 +0,0 @@
1
- /**
2
- * Diagnostics
3
- *
4
- * Compile-time validation mirroring Zenith contracts.
5
- * No runtime execution. Pure static analysis only.
6
- */
7
-
8
- import * as path from 'path';
9
-
10
- import { parseForExpression } from './metadata/directive-metadata';
11
- import { parseZenithImports, resolveModule, isPluginModule } from './imports';
12
- import type { ProjectGraph } from './project';
13
- import {
14
- classifyZenithFile,
15
- isCssContractImportSpecifier,
16
- isLocalCssSpecifier,
17
- resolveCssImportPath
18
- } from './contracts';
19
- import type { ZenithServerSettings } from './settings';
20
- import { EVENT_BINDING_DIAGNOSTIC_CODE } from './code-actions';
21
-
22
- const COMPONENT_SCRIPT_CONTRACT_MESSAGE =
23
- 'Zenith Contract Violation: Components are structural; move <script> to the parent route scope.';
24
-
25
- const CSS_BARE_IMPORT_MESSAGE =
26
- 'CSS import contract violation: bare CSS imports are not supported.';
27
-
28
- const CSS_ESCAPE_MESSAGE =
29
- 'CSS import contract violation: imported CSS path escapes project root.';
30
-
31
- const DiagnosticSeverity = {
32
- Error: 1,
33
- Warning: 2,
34
- Information: 3,
35
- Hint: 4
36
- } as const;
37
-
38
- export interface ZenithPosition {
39
- line: number;
40
- character: number;
41
- }
42
-
43
- export interface ZenithRange {
44
- start: ZenithPosition;
45
- end: ZenithPosition;
46
- }
47
-
48
- export interface ZenithDiagnostic {
49
- severity: number;
50
- range: ZenithRange;
51
- message: string;
52
- source: string;
53
- code?: string;
54
- data?: unknown;
55
- }
56
-
57
- export interface ZenithTextDocumentLike {
58
- uri: string;
59
- getText(): string;
60
- positionAt(offset: number): ZenithPosition;
61
- }
62
-
63
- function uriToFilePath(uri: string): string {
64
- try {
65
- return decodeURIComponent(new URL(uri).pathname);
66
- } catch {
67
- return decodeURIComponent(uri.replace('file://', ''));
68
- }
69
- }
70
-
71
- function stripScriptAndStylePreserveIndices(text: string): string {
72
- return text.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, (match) => ' '.repeat(match.length));
73
- }
74
-
75
- interface ScriptBlock {
76
- content: string;
77
- contentStartOffset: number;
78
- }
79
-
80
- function getScriptBlocks(text: string): ScriptBlock[] {
81
- const blocks: ScriptBlock[] = [];
82
- const scriptPattern = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
83
- let match: RegExpExecArray | null;
84
-
85
- while ((match = scriptPattern.exec(text)) !== null) {
86
- const whole = match[0] || '';
87
- const content = match[1] || '';
88
- const localStart = whole.indexOf(content);
89
- const contentStartOffset = (match.index || 0) + Math.max(localStart, 0);
90
- blocks.push({ content, contentStartOffset });
91
- }
92
-
93
- return blocks;
94
- }
95
-
96
- interface ParsedImportSpecifier {
97
- specifier: string;
98
- startOffset: number;
99
- endOffset: number;
100
- }
101
-
102
- function parseImportSpecifiers(scriptContent: string, scriptStartOffset: number): ParsedImportSpecifier[] {
103
- const imports: ParsedImportSpecifier[] = [];
104
- const importPattern = /import\s+(?:[^'";]+?\s+from\s+)?['"]([^'"\n]+)['"]/g;
105
- let match: RegExpExecArray | null;
106
-
107
- while ((match = importPattern.exec(scriptContent)) !== null) {
108
- const statement = match[0] || '';
109
- const specifier = match[1] || '';
110
- const rel = statement.indexOf(specifier);
111
- const startOffset = scriptStartOffset + (match.index || 0) + Math.max(rel, 0);
112
- const endOffset = startOffset + specifier.length;
113
- imports.push({ specifier, startOffset, endOffset });
114
- }
115
-
116
- return imports;
117
- }
118
-
119
- function normalizeEventHandlerValue(rawValue: string): string {
120
- let value = rawValue.trim();
121
-
122
- if ((value.startsWith('{') && value.endsWith('}')) ||
123
- (value.startsWith('"') && value.endsWith('"')) ||
124
- (value.startsWith("'") && value.endsWith("'"))) {
125
- value = value.slice(1, -1).trim();
126
- }
127
-
128
- if (/^[a-zA-Z_$][a-zA-Z0-9_$]*\(\)$/.test(value)) {
129
- value = value.slice(0, -2);
130
- }
131
-
132
- if (!value) {
133
- return 'handler';
134
- }
135
-
136
- return value;
137
- }
138
-
139
- /**
140
- * Collect all diagnostics for a document.
141
- */
142
- export async function collectDiagnostics(
143
- document: ZenithTextDocumentLike,
144
- graph: ProjectGraph | null,
145
- settings: ZenithServerSettings,
146
- projectRoot: string | null
147
- ): Promise<ZenithDiagnostic[]> {
148
- const diagnostics: ZenithDiagnostic[] = [];
149
- const text = document.getText();
150
- const filePath = uriToFilePath(document.uri);
151
-
152
- let hasComponentScriptCompilerDiagnostic = false;
153
-
154
- // 1) Compiler validation (source-of-truth), with configurable suppression for component script contract.
155
- try {
156
- process.env.ZENITH_CACHE = '1';
157
- const { compile } = await import('@zenithbuild/compiler');
158
- const result = await compile(text, filePath);
159
-
160
- // 2) Surface ZEN-DOM-* warnings from compiler JSON as LSP diagnostics.
161
- interface CompilerWarning {
162
- code?: string;
163
- message?: string;
164
- range?: { start?: { line?: number; column?: number }; end?: { line?: number; column?: number } };
165
- }
166
- const warnings: CompilerWarning[] = (result as { warnings?: CompilerWarning[] }).warnings ?? [];
167
- const domLintSeverity = settings.strictDomLints ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning;
168
- for (const w of warnings) {
169
- const range = w.range;
170
- const startLine = (range?.start?.line ?? 1) - 1;
171
- const startChar = (range?.start?.column ?? 1) - 1;
172
- const endLine = (range?.end?.line ?? range?.start?.line ?? 1) - 1;
173
- const endChar = (range?.end?.column ?? range?.start?.column ?? 1);
174
- diagnostics.push({
175
- severity: domLintSeverity,
176
- range: {
177
- start: { line: startLine, character: startChar },
178
- end: { line: endLine, character: endChar }
179
- },
180
- message: w.message ?? 'DOM lint',
181
- source: 'zenith-compiler',
182
- code: w.code
183
- });
184
- }
185
- } catch (error: any) {
186
- const message = String(error?.message || 'Unknown compiler error');
187
- const isContractViolation = message.includes(COMPONENT_SCRIPT_CONTRACT_MESSAGE);
188
-
189
- if (isContractViolation) {
190
- hasComponentScriptCompilerDiagnostic = true;
191
- }
192
-
193
- if (!(settings.componentScripts === 'allow' && isContractViolation)) {
194
- diagnostics.push({
195
- severity: DiagnosticSeverity.Error,
196
- range: {
197
- start: { line: (error?.line || 1) - 1, character: (error?.column || 1) - 1 },
198
- end: { line: (error?.line || 1) - 1, character: (error?.column || 1) + 20 }
199
- },
200
- message: `[${error?.code || 'compiler'}] ${message}${error?.hints ? '\n\nHints:\n' + error.hints.join('\n') : ''}`,
201
- source: 'zenith-compiler'
202
- });
203
- }
204
- }
205
-
206
- diagnostics.push(
207
- ...collectContractDiagnostics(
208
- document,
209
- graph,
210
- settings,
211
- projectRoot,
212
- hasComponentScriptCompilerDiagnostic
213
- )
214
- );
215
-
216
- return diagnostics;
217
- }
218
-
219
- export function collectContractDiagnostics(
220
- document: ZenithTextDocumentLike,
221
- graph: ProjectGraph | null,
222
- settings: ZenithServerSettings,
223
- projectRoot: string | null,
224
- hasComponentScriptCompilerDiagnostic = false
225
- ): ZenithDiagnostic[] {
226
- const diagnostics: ZenithDiagnostic[] = [];
227
- const text = document.getText();
228
- const filePath = uriToFilePath(document.uri);
229
-
230
- collectComponentScriptDiagnostics(document, text, filePath, settings, diagnostics, hasComponentScriptCompilerDiagnostic);
231
- collectEventBindingDiagnostics(document, text, diagnostics);
232
- collectDirectiveDiagnostics(document, text, diagnostics);
233
- collectImportDiagnostics(document, text, diagnostics);
234
- collectCssImportContractDiagnostics(document, text, filePath, projectRoot, diagnostics);
235
- collectExpressionDiagnostics(document, text, diagnostics);
236
- collectComponentDiagnostics(document, text, graph, diagnostics);
237
-
238
- return diagnostics;
239
- }
240
-
241
- function collectComponentScriptDiagnostics(
242
- document: ZenithTextDocumentLike,
243
- text: string,
244
- filePath: string,
245
- settings: ZenithServerSettings,
246
- diagnostics: ZenithDiagnostic[],
247
- hasComponentScriptCompilerDiagnostic: boolean
248
- ): void {
249
- if (settings.componentScripts !== 'forbid') {
250
- return;
251
- }
252
-
253
- if (classifyZenithFile(filePath) !== 'component') {
254
- return;
255
- }
256
-
257
- if (hasComponentScriptCompilerDiagnostic) {
258
- return;
259
- }
260
-
261
- const scriptTagMatch = /<script\b[^>]*>/i.exec(text);
262
- if (!scriptTagMatch || scriptTagMatch.index == null) {
263
- return;
264
- }
265
-
266
- diagnostics.push({
267
- severity: DiagnosticSeverity.Error,
268
- range: {
269
- start: document.positionAt(scriptTagMatch.index),
270
- end: document.positionAt(scriptTagMatch.index + scriptTagMatch[0].length)
271
- },
272
- message: COMPONENT_SCRIPT_CONTRACT_MESSAGE,
273
- source: 'zenith-contract'
274
- });
275
- }
276
-
277
- function collectEventBindingDiagnostics(
278
- document: ZenithTextDocumentLike,
279
- text: string,
280
- diagnostics: ZenithDiagnostic[]
281
- ): void {
282
- const stripped = stripScriptAndStylePreserveIndices(text);
283
-
284
- // Invalid @click={handler}
285
- const atEventPattern = /@([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g;
286
- let match: RegExpExecArray | null;
287
-
288
- while ((match = atEventPattern.exec(stripped)) !== null) {
289
- const fullMatch = match[0] || '';
290
- const eventName = match[1] || 'click';
291
- const rawHandler = match[2] || '{handler}';
292
- const handler = normalizeEventHandlerValue(rawHandler);
293
- const replacement = `on:${eventName}={${handler}}`;
294
-
295
- diagnostics.push({
296
- severity: DiagnosticSeverity.Error,
297
- range: {
298
- start: document.positionAt(match.index || 0),
299
- end: document.positionAt((match.index || 0) + fullMatch.length)
300
- },
301
- message: `Invalid event binding syntax. Use on:${eventName}={handler}.`,
302
- source: 'zenith-contract',
303
- code: EVENT_BINDING_DIAGNOSTIC_CODE,
304
- data: {
305
- replacement,
306
- title: `Convert to ${replacement}`
307
- }
308
- });
309
- }
310
-
311
- // Invalid onclick="handler" / onclick={handler}
312
- const onEventPattern = /\bon([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(\{[^}]*\}|"[^"]*"|'[^']*')/g;
313
- while ((match = onEventPattern.exec(stripped)) !== null) {
314
- const fullMatch = match[0] || '';
315
- const eventName = match[1] || 'click';
316
- const rawHandler = match[2] || '{handler}';
317
- const handler = normalizeEventHandlerValue(rawHandler);
318
- const replacement = `on:${eventName}={${handler}}`;
319
-
320
- diagnostics.push({
321
- severity: DiagnosticSeverity.Error,
322
- range: {
323
- start: document.positionAt(match.index || 0),
324
- end: document.positionAt((match.index || 0) + fullMatch.length)
325
- },
326
- message: `Invalid event binding syntax. Use on:${eventName}={handler}.`,
327
- source: 'zenith-contract',
328
- code: EVENT_BINDING_DIAGNOSTIC_CODE,
329
- data: {
330
- replacement,
331
- title: `Convert to ${replacement}`
332
- }
333
- });
334
- }
335
- }
336
-
337
- function collectCssImportContractDiagnostics(
338
- document: ZenithTextDocumentLike,
339
- text: string,
340
- filePath: string,
341
- projectRoot: string | null,
342
- diagnostics: ZenithDiagnostic[]
343
- ): void {
344
- const scriptBlocks = getScriptBlocks(text);
345
- if (scriptBlocks.length === 0) {
346
- return;
347
- }
348
-
349
- const effectiveProjectRoot = projectRoot ? path.resolve(projectRoot) : path.dirname(filePath);
350
-
351
- for (const block of scriptBlocks) {
352
- const imports = parseImportSpecifiers(block.content, block.contentStartOffset);
353
- for (const imp of imports) {
354
- if (!isCssContractImportSpecifier(imp.specifier)) {
355
- continue;
356
- }
357
-
358
- if (!isLocalCssSpecifier(imp.specifier)) {
359
- diagnostics.push({
360
- severity: DiagnosticSeverity.Error,
361
- range: {
362
- start: document.positionAt(imp.startOffset),
363
- end: document.positionAt(imp.endOffset)
364
- },
365
- message: CSS_BARE_IMPORT_MESSAGE,
366
- source: 'zenith-contract'
367
- });
368
- continue;
369
- }
370
-
371
- const resolved = resolveCssImportPath(filePath, imp.specifier, effectiveProjectRoot);
372
- if (resolved.escapesProjectRoot) {
373
- diagnostics.push({
374
- severity: DiagnosticSeverity.Error,
375
- range: {
376
- start: document.positionAt(imp.startOffset),
377
- end: document.positionAt(imp.endOffset)
378
- },
379
- message: CSS_ESCAPE_MESSAGE,
380
- source: 'zenith-contract'
381
- });
382
- }
383
- }
384
- }
385
- }
386
-
387
- /**
388
- * Validate component references.
389
- */
390
- function collectComponentDiagnostics(
391
- document: ZenithTextDocumentLike,
392
- text: string,
393
- graph: ProjectGraph | null,
394
- diagnostics: ZenithDiagnostic[]
395
- ): void {
396
- if (!graph) return;
397
-
398
- const strippedText = text
399
- .replace(/<(script|style)[^>]*>([\s\S]*?)<\/\1>/gi, (match, _tag, content) => {
400
- return match.replace(content, ' '.repeat(content.length));
401
- });
402
-
403
- const componentPattern = /<([A-Z][a-zA-Z0-9]*)(?=[\s/>])/g;
404
- let match: RegExpExecArray | null;
405
-
406
- while ((match = componentPattern.exec(strippedText)) !== null) {
407
- const componentName = match[1];
408
- if (componentName === 'ZenLink') continue;
409
-
410
- const inLayouts = graph.layouts.has(componentName);
411
- const inComponents = graph.components.has(componentName);
412
-
413
- if (!inLayouts && !inComponents) {
414
- const startPos = document.positionAt((match.index || 0) + 1);
415
- const endPos = document.positionAt((match.index || 0) + 1 + componentName.length);
416
-
417
- diagnostics.push({
418
- severity: DiagnosticSeverity.Warning,
419
- range: { start: startPos, end: endPos },
420
- message: `Unknown component: '<${componentName}>'. Ensure it exists in src/layouts/ or src/components/`,
421
- source: 'zenith'
422
- });
423
- }
424
- }
425
- }
426
-
427
- /**
428
- * Validate directive usage.
429
- */
430
- function collectDirectiveDiagnostics(
431
- document: ZenithTextDocumentLike,
432
- text: string,
433
- diagnostics: ZenithDiagnostic[]
434
- ): void {
435
- const directivePattern = /(zen:(?:if|for|effect|show))\s*=\s*["']([^"']*)["']/g;
436
- let match: RegExpExecArray | null;
437
-
438
- while ((match = directivePattern.exec(text)) !== null) {
439
- const directiveName = match[1];
440
- const directiveValue = match[2];
441
-
442
- if (directiveName === 'zen:for') {
443
- const parsed = parseForExpression(directiveValue);
444
- if (!parsed) {
445
- const startPos = document.positionAt(match.index || 0);
446
- const endPos = document.positionAt((match.index || 0) + (match[0] || '').length);
447
-
448
- diagnostics.push({
449
- severity: DiagnosticSeverity.Error,
450
- range: { start: startPos, end: endPos },
451
- message: 'Invalid zen:for syntax. Expected: "item in items" or "item, index in items"',
452
- source: 'zenith'
453
- });
454
- }
455
- }
456
-
457
- if (!directiveValue.trim()) {
458
- const startPos = document.positionAt(match.index || 0);
459
- const endPos = document.positionAt((match.index || 0) + (match[0] || '').length);
460
-
461
- diagnostics.push({
462
- severity: DiagnosticSeverity.Error,
463
- range: { start: startPos, end: endPos },
464
- message: `${directiveName} requires a value`,
465
- source: 'zenith'
466
- });
467
- }
468
- }
469
-
470
- const slotForPattern = /<slot[^>]*zen:for/g;
471
- while ((match = slotForPattern.exec(text)) !== null) {
472
- const startPos = document.positionAt(match.index || 0);
473
- const endPos = document.positionAt((match.index || 0) + (match[0] || '').length);
474
-
475
- diagnostics.push({
476
- severity: DiagnosticSeverity.Error,
477
- range: { start: startPos, end: endPos },
478
- message: 'zen:for cannot be used on <slot> elements',
479
- source: 'zenith'
480
- });
481
- }
482
- }
483
-
484
- /**
485
- * Validate imports.
486
- */
487
- function collectImportDiagnostics(
488
- document: ZenithTextDocumentLike,
489
- text: string,
490
- diagnostics: ZenithDiagnostic[]
491
- ): void {
492
- const scriptMatch = text.match(/<script[^>]*>([\s\S]*?)<\/script>/i);
493
- if (!scriptMatch) return;
494
-
495
- const scriptContent = scriptMatch[1];
496
- const scriptStart = (scriptMatch.index || 0) + scriptMatch[0].indexOf(scriptContent);
497
- const imports = parseZenithImports(scriptContent);
498
-
499
- for (const imp of imports) {
500
- const resolved = resolveModule(imp.module);
501
-
502
- if (isPluginModule(imp.module) && !resolved.isKnown) {
503
- const importPattern = new RegExp(`import[^'\"]*['\"]${imp.module.replace(':', '\\:')}['\"]`);
504
- const importMatch = scriptContent.match(importPattern);
505
-
506
- if (importMatch) {
507
- const importOffset = scriptStart + (importMatch.index || 0);
508
- const startPos = document.positionAt(importOffset);
509
- const endPos = document.positionAt(importOffset + importMatch[0].length);
510
-
511
- diagnostics.push({
512
- severity: DiagnosticSeverity.Information,
513
- range: { start: startPos, end: endPos },
514
- message: `Unknown plugin module: '${imp.module}'. Make sure the plugin is installed.`,
515
- source: 'zenith'
516
- });
517
- }
518
- }
519
-
520
- if (resolved.isKnown && resolved.metadata) {
521
- const validExports = resolved.metadata.exports.map((e) => e.name);
522
-
523
- for (const specifier of imp.specifiers) {
524
- if (!validExports.includes(specifier)) {
525
- const specPattern = new RegExp(`\\b${specifier}\\b`);
526
- const specMatch = scriptContent.match(specPattern);
527
-
528
- if (specMatch) {
529
- const specOffset = scriptStart + (specMatch.index || 0);
530
- const startPos = document.positionAt(specOffset);
531
- const endPos = document.positionAt(specOffset + specifier.length);
532
-
533
- diagnostics.push({
534
- severity: DiagnosticSeverity.Warning,
535
- range: { start: startPos, end: endPos },
536
- message: `'${specifier}' is not exported from '${imp.module}'`,
537
- source: 'zenith'
538
- });
539
- }
540
- }
541
- }
542
- }
543
- }
544
- }
545
-
546
- /**
547
- * Validate expressions for dangerous patterns.
548
- */
549
- function collectExpressionDiagnostics(
550
- document: ZenithTextDocumentLike,
551
- text: string,
552
- diagnostics: ZenithDiagnostic[]
553
- ): void {
554
- const expressionPattern = /\{([^}]+)\}/g;
555
- let match: RegExpExecArray | null;
556
-
557
- while ((match = expressionPattern.exec(text)) !== null) {
558
- const expression = match[1];
559
- const offset = match.index || 0;
560
-
561
- if (expression.includes('eval(') || expression.includes('Function(')) {
562
- const startPos = document.positionAt(offset);
563
- const endPos = document.positionAt(offset + (match[0] || '').length);
564
-
565
- diagnostics.push({
566
- severity: DiagnosticSeverity.Error,
567
- range: { start: startPos, end: endPos },
568
- message: 'Dangerous pattern detected: eval() and Function() are not allowed in expressions',
569
- source: 'zenith'
570
- });
571
- }
572
-
573
- if (/\bwith\s*\(/.test(expression)) {
574
- const startPos = document.positionAt(offset);
575
- const endPos = document.positionAt(offset + (match[0] || '').length);
576
-
577
- diagnostics.push({
578
- severity: DiagnosticSeverity.Error,
579
- range: { start: startPos, end: endPos },
580
- message: "'with' statement is not allowed in expressions",
581
- source: 'zenith'
582
- });
583
- }
584
-
585
- if (expression.includes(' as ') || (expression.includes('<') && expression.includes('>'))) {
586
- const startPos = document.positionAt(offset);
587
- const endPos = document.positionAt(offset + (match[0] || '').length);
588
-
589
- diagnostics.push({
590
- severity: DiagnosticSeverity.Error,
591
- range: { start: startPos, end: endPos },
592
- message: 'TypeScript syntax (type casting or generics) detected in runtime expression. Runtime code must be pure JavaScript.',
593
- source: 'zenith'
594
- });
595
- }
596
- }
597
- }
598
-
599
- export const CONTRACT_MESSAGES = {
600
- componentScript: COMPONENT_SCRIPT_CONTRACT_MESSAGE,
601
- cssBareImport: CSS_BARE_IMPORT_MESSAGE,
602
- cssEscape: CSS_ESCAPE_MESSAGE
603
- } as const;