@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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Diagnostics
3
+ *
4
+ * Compile-time validation mirroring the Zenith compiler.
5
+ * The LSP must surface compiler-level errors early.
6
+ *
7
+ * Important: No runtime execution. Pure static analysis only.
8
+ */
9
+
10
+ import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver/node';
11
+ import { TextDocument } from 'vscode-languageserver-textdocument';
12
+ import { isDirective, parseForExpression } from './metadata/directive-metadata';
13
+ import { parseZenithImports, resolveModule, isPluginModule } from './imports';
14
+ import type { ProjectGraph } from './project';
15
+
16
+ export interface ValidationContext {
17
+ document: TextDocument;
18
+ text: string;
19
+ graph: ProjectGraph | null;
20
+ }
21
+
22
+ /**
23
+ * Collect all diagnostics for a document
24
+ */
25
+ export function collectDiagnostics(document: TextDocument, graph: ProjectGraph | null): Diagnostic[] {
26
+ const diagnostics: Diagnostic[] = [];
27
+ const text = document.getText();
28
+
29
+ // Validate component references
30
+ collectComponentDiagnostics(document, text, graph, diagnostics);
31
+
32
+ // Validate directive usage
33
+ collectDirectiveDiagnostics(document, text, diagnostics);
34
+
35
+ // Validate imports
36
+ collectImportDiagnostics(document, text, diagnostics);
37
+
38
+ // Validate expressions
39
+ collectExpressionDiagnostics(document, text, diagnostics);
40
+
41
+ return diagnostics;
42
+ }
43
+
44
+ /**
45
+ * Validate component references
46
+ */
47
+ function collectComponentDiagnostics(
48
+ document: TextDocument,
49
+ text: string,
50
+ graph: ProjectGraph | null,
51
+ diagnostics: Diagnostic[]
52
+ ): void {
53
+ if (!graph) return;
54
+
55
+ // Strip script and style blocks to avoid matching PascalCase names in TS generics
56
+ // We use a simplified version that preserves indices by replacing content with spaces
57
+ const strippedText = text
58
+ .replace(/<(script|style)[^>]*>([\s\S]*?)<\/\1>/gi, (match, tag, content) => {
59
+ return match.replace(content, ' '.repeat(content.length));
60
+ });
61
+
62
+ // Match component tags (PascalCase)
63
+ const componentPattern = /<([A-Z][a-zA-Z0-9]*)(?=[\s/>])/g;
64
+ let match;
65
+
66
+ while ((match = componentPattern.exec(strippedText)) !== null) {
67
+ const componentName = match[1];
68
+
69
+ // Skip known router components
70
+ if (componentName === 'ZenLink') continue;
71
+
72
+ // Check if component exists in project graph
73
+ const inLayouts = graph.layouts.has(componentName);
74
+ const inComponents = graph.components.has(componentName);
75
+
76
+ if (!inLayouts && !inComponents) {
77
+ const startPos = document.positionAt(match.index + 1);
78
+ const endPos = document.positionAt(match.index + 1 + componentName.length);
79
+
80
+ diagnostics.push({
81
+ severity: DiagnosticSeverity.Warning,
82
+ range: { start: startPos, end: endPos },
83
+ message: `Unknown component: '<${componentName}>'. Ensure it exists in src/layouts/ or src/components/`,
84
+ source: 'zenith'
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Validate directive usage
92
+ */
93
+ function collectDirectiveDiagnostics(
94
+ document: TextDocument,
95
+ text: string,
96
+ diagnostics: Diagnostic[]
97
+ ): void {
98
+ // Match zen:* directives
99
+ const directivePattern = /(zen:(?:if|for|effect|show))\s*=\s*["']([^"']*)["']/g;
100
+ let match;
101
+
102
+ while ((match = directivePattern.exec(text)) !== null) {
103
+ const directiveName = match[1];
104
+ const directiveValue = match[2];
105
+
106
+ // Validate zen:for syntax
107
+ if (directiveName === 'zen:for') {
108
+ const parsed = parseForExpression(directiveValue);
109
+ if (!parsed) {
110
+ const startPos = document.positionAt(match.index);
111
+ const endPos = document.positionAt(match.index + match[0].length);
112
+
113
+ diagnostics.push({
114
+ severity: DiagnosticSeverity.Error,
115
+ range: { start: startPos, end: endPos },
116
+ message: `Invalid zen:for syntax. Expected: "item in items" or "item, index in items"`,
117
+ source: 'zenith'
118
+ });
119
+ }
120
+ }
121
+
122
+ // Check for empty directive values
123
+ if (!directiveValue.trim()) {
124
+ const startPos = document.positionAt(match.index);
125
+ const endPos = document.positionAt(match.index + match[0].length);
126
+
127
+ diagnostics.push({
128
+ severity: DiagnosticSeverity.Error,
129
+ range: { start: startPos, end: endPos },
130
+ message: `${directiveName} requires a value`,
131
+ source: 'zenith'
132
+ });
133
+ }
134
+ }
135
+
136
+ // Check for zen:for on slot elements (forbidden)
137
+ const slotForPattern = /<slot[^>]*zen:for/g;
138
+ while ((match = slotForPattern.exec(text)) !== null) {
139
+ const startPos = document.positionAt(match.index);
140
+ const endPos = document.positionAt(match.index + match[0].length);
141
+
142
+ diagnostics.push({
143
+ severity: DiagnosticSeverity.Error,
144
+ range: { start: startPos, end: endPos },
145
+ message: `zen:for cannot be used on <slot> elements`,
146
+ source: 'zenith'
147
+ });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Validate imports
153
+ */
154
+ function collectImportDiagnostics(
155
+ document: TextDocument,
156
+ text: string,
157
+ diagnostics: Diagnostic[]
158
+ ): void {
159
+ // Extract script content
160
+ const scriptMatch = text.match(/<script[^>]*>([\s\S]*?)<\/script>/i);
161
+ if (!scriptMatch) return;
162
+
163
+ const scriptContent = scriptMatch[1];
164
+ const scriptStart = scriptMatch.index! + scriptMatch[0].indexOf(scriptContent);
165
+
166
+ const imports = parseZenithImports(scriptContent);
167
+
168
+ for (const imp of imports) {
169
+ const resolved = resolveModule(imp.module);
170
+
171
+ // Warn about unknown plugin modules (soft diagnostic)
172
+ if (isPluginModule(imp.module) && !resolved.isKnown) {
173
+ // Find the import line in the document
174
+ const importPattern = new RegExp(`import[^'"]*['"]${imp.module.replace(':', '\\:')}['"]`);
175
+ const importMatch = scriptContent.match(importPattern);
176
+
177
+ if (importMatch) {
178
+ const importOffset = scriptStart + (importMatch.index || 0);
179
+ const startPos = document.positionAt(importOffset);
180
+ const endPos = document.positionAt(importOffset + importMatch[0].length);
181
+
182
+ diagnostics.push({
183
+ severity: DiagnosticSeverity.Information,
184
+ range: { start: startPos, end: endPos },
185
+ message: `Unknown plugin module: '${imp.module}'. Make sure the plugin is installed.`,
186
+ source: 'zenith'
187
+ });
188
+ }
189
+ }
190
+
191
+ // Check for invalid specifiers in known modules
192
+ if (resolved.isKnown && resolved.metadata) {
193
+ const validExports = resolved.metadata.exports.map(e => e.name);
194
+
195
+ for (const specifier of imp.specifiers) {
196
+ if (!validExports.includes(specifier)) {
197
+ const specPattern = new RegExp(`\\b${specifier}\\b`);
198
+ const specMatch = scriptContent.match(specPattern);
199
+
200
+ if (specMatch) {
201
+ const specOffset = scriptStart + (specMatch.index || 0);
202
+ const startPos = document.positionAt(specOffset);
203
+ const endPos = document.positionAt(specOffset + specifier.length);
204
+
205
+ diagnostics.push({
206
+ severity: DiagnosticSeverity.Warning,
207
+ range: { start: startPos, end: endPos },
208
+ message: `'${specifier}' is not exported from '${imp.module}'`,
209
+ source: 'zenith'
210
+ });
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Validate expressions for dangerous patterns
220
+ */
221
+ function collectExpressionDiagnostics(
222
+ document: TextDocument,
223
+ text: string,
224
+ diagnostics: Diagnostic[]
225
+ ): void {
226
+ // Match expressions in templates
227
+ const expressionPattern = /\{([^}]+)\}/g;
228
+ let match;
229
+
230
+ while ((match = expressionPattern.exec(text)) !== null) {
231
+ const expression = match[1];
232
+ const offset = match.index;
233
+
234
+ // Check for dangerous patterns
235
+ if (expression.includes('eval(') || expression.includes('Function(')) {
236
+ const startPos = document.positionAt(offset);
237
+ const endPos = document.positionAt(offset + match[0].length);
238
+
239
+ diagnostics.push({
240
+ severity: DiagnosticSeverity.Error,
241
+ range: { start: startPos, end: endPos },
242
+ message: `Dangerous pattern detected: eval() and Function() are not allowed in expressions`,
243
+ source: 'zenith'
244
+ });
245
+ }
246
+
247
+ // Check for with statement
248
+ if (/\bwith\s*\(/.test(expression)) {
249
+ const startPos = document.positionAt(offset);
250
+ const endPos = document.positionAt(offset + match[0].length);
251
+
252
+ diagnostics.push({
253
+ severity: DiagnosticSeverity.Error,
254
+ range: { start: startPos, end: endPos },
255
+ message: `'with' statement is not allowed in expressions`,
256
+ source: 'zenith'
257
+ });
258
+ }
259
+ }
260
+ }
package/src/imports.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Import Resolution & Awareness
3
+ *
4
+ * Handles recognition and resolution of Zenith imports.
5
+ * - zenith/* imports are core modules (virtual, symbolic resolution)
6
+ * - zenith:* imports are plugin modules (soft diagnostics if missing)
7
+ */
8
+
9
+ import {
10
+ CORE_MODULES,
11
+ getCoreModule,
12
+ getCoreExport,
13
+ isCoreModule,
14
+ type CoreModuleMetadata,
15
+ type ModuleExport
16
+ } from './metadata/core-imports';
17
+
18
+ import {
19
+ PLUGIN_MODULES,
20
+ getPluginModule,
21
+ getPluginExport,
22
+ isPluginModule,
23
+ isKnownPluginModule,
24
+ type PluginModuleMetadata,
25
+ type PluginExport
26
+ } from './metadata/plugin-imports';
27
+
28
+ export interface ParsedImport {
29
+ module: string;
30
+ specifiers: string[];
31
+ isType: boolean;
32
+ line: number;
33
+ }
34
+
35
+ export interface ResolvedImport {
36
+ module: string;
37
+ kind: 'core' | 'plugin' | 'external';
38
+ metadata?: CoreModuleMetadata | PluginModuleMetadata;
39
+ isKnown: boolean;
40
+ }
41
+
42
+ /**
43
+ * Parse Zenith imports from script content
44
+ */
45
+ export function parseZenithImports(script: string): ParsedImport[] {
46
+ const imports: ParsedImport[] = [];
47
+ const lines = script.split('\n');
48
+
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const line = lines[i];
51
+
52
+ // Match: import { x, y } from 'module' or import type { x } from 'module'
53
+ const importMatch = line.match(/import\s+(type\s+)?(?:\{([^}]+)\}|(\*\s+as\s+\w+)|(\w+))\s+from\s+['"]([^'"]+)['"]/);
54
+
55
+ if (importMatch) {
56
+ const isType = !!importMatch[1];
57
+ const namedImports = importMatch[2];
58
+ const namespaceImport = importMatch[3];
59
+ const defaultImport = importMatch[4];
60
+ const moduleName = importMatch[5];
61
+
62
+ // Only track zenith imports
63
+ if (moduleName.startsWith('zenith') || moduleName.startsWith('zenith:')) {
64
+ const specifiers: string[] = [];
65
+
66
+ if (namedImports) {
67
+ // Parse named imports: { a, b as c, d }
68
+ const parts = namedImports.split(',');
69
+ for (const part of parts) {
70
+ const cleaned = part.trim().split(/\s+as\s+/)[0].trim();
71
+ if (cleaned) specifiers.push(cleaned);
72
+ }
73
+ } else if (namespaceImport) {
74
+ specifiers.push(namespaceImport.trim());
75
+ } else if (defaultImport) {
76
+ specifiers.push(defaultImport);
77
+ }
78
+
79
+ imports.push({
80
+ module: moduleName,
81
+ specifiers,
82
+ isType,
83
+ line: i + 1
84
+ });
85
+ }
86
+ }
87
+
88
+ // Match: import 'module' (side-effect import)
89
+ const sideEffectMatch = line.match(/import\s+['"]([^'"]+)['"]/);
90
+ if (sideEffectMatch && !importMatch) {
91
+ const moduleName = sideEffectMatch[1];
92
+ if (moduleName.startsWith('zenith') || moduleName.startsWith('zenith:')) {
93
+ imports.push({
94
+ module: moduleName,
95
+ specifiers: [],
96
+ isType: false,
97
+ line: i + 1
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ return imports;
104
+ }
105
+
106
+ /**
107
+ * Resolve a module name to its metadata
108
+ */
109
+ export function resolveModule(moduleName: string): ResolvedImport {
110
+ if (isCoreModule(moduleName)) {
111
+ return {
112
+ module: moduleName,
113
+ kind: 'core',
114
+ metadata: getCoreModule(moduleName),
115
+ isKnown: true
116
+ };
117
+ }
118
+
119
+ if (isPluginModule(moduleName)) {
120
+ return {
121
+ module: moduleName,
122
+ kind: 'plugin',
123
+ metadata: getPluginModule(moduleName),
124
+ isKnown: isKnownPluginModule(moduleName)
125
+ };
126
+ }
127
+
128
+ return {
129
+ module: moduleName,
130
+ kind: 'external',
131
+ isKnown: false
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Get export metadata for a specific import
137
+ */
138
+ export function resolveExport(moduleName: string, exportName: string): ModuleExport | PluginExport | undefined {
139
+ if (isCoreModule(moduleName)) {
140
+ return getCoreExport(moduleName, exportName);
141
+ }
142
+
143
+ if (isKnownPluginModule(moduleName)) {
144
+ return getPluginExport(moduleName, exportName);
145
+ }
146
+
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Check if router is imported in the given imports
152
+ */
153
+ export function hasRouterImport(imports: ParsedImport[]): boolean {
154
+ return imports.some(i => i.module === 'zenith/router');
155
+ }
156
+
157
+ /**
158
+ * Check if a specific export is imported
159
+ */
160
+ export function hasImport(imports: ParsedImport[], exportName: string): boolean {
161
+ return imports.some(i => i.specifiers.includes(exportName));
162
+ }
163
+
164
+ /**
165
+ * Get all available modules for completion
166
+ */
167
+ export function getAllModules(): Array<{ module: string; kind: 'core' | 'plugin'; description: string }> {
168
+ const modules: Array<{ module: string; kind: 'core' | 'plugin'; description: string }> = [];
169
+
170
+ for (const [name, meta] of Object.entries(CORE_MODULES)) {
171
+ modules.push({
172
+ module: name,
173
+ kind: 'core',
174
+ description: meta.description
175
+ });
176
+ }
177
+
178
+ for (const [name, meta] of Object.entries(PLUGIN_MODULES)) {
179
+ modules.push({
180
+ module: name,
181
+ kind: 'plugin',
182
+ description: meta.description
183
+ });
184
+ }
185
+
186
+ return modules;
187
+ }
188
+
189
+ /**
190
+ * Get exports for completion from a module
191
+ */
192
+ export function getModuleExports(moduleName: string): Array<ModuleExport | PluginExport> {
193
+ const coreModule = getCoreModule(moduleName);
194
+ if (coreModule) return coreModule.exports;
195
+
196
+ const pluginModule = getPluginModule(moduleName);
197
+ if (pluginModule) return pluginModule.exports;
198
+
199
+ return [];
200
+ }
201
+
202
+ // Re-export utilities
203
+ export { isPluginModule } from './metadata/plugin-imports';
204
+
205
+ // Re-export types
206
+ export type { CoreModuleMetadata, ModuleExport } from './metadata/core-imports';
207
+ export type { PluginModuleMetadata, PluginExport } from './metadata/plugin-imports';
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Core Import Metadata
3
+ *
4
+ * Static metadata for Zenith core modules.
5
+ * These are virtual modules resolved symbolically (no FS probing).
6
+ */
7
+
8
+ export interface ModuleExport {
9
+ name: string;
10
+ kind: 'function' | 'component' | 'type' | 'variable';
11
+ description: string;
12
+ signature?: string;
13
+ }
14
+
15
+ export interface CoreModuleMetadata {
16
+ module: string;
17
+ description: string;
18
+ exports: ModuleExport[];
19
+ }
20
+
21
+ /**
22
+ * Core Zenith module exports
23
+ */
24
+ export const CORE_MODULES: Record<string, CoreModuleMetadata> = {
25
+ 'zenith': {
26
+ module: 'zenith',
27
+ description: 'Core Zenith runtime primitives and lifecycle hooks.',
28
+ exports: [
29
+ {
30
+ name: 'zenEffect',
31
+ kind: 'function',
32
+ description: 'Reactive effect that re-runs when dependencies change.',
33
+ signature: 'zenEffect(callback: () => void | (() => void)): void'
34
+ },
35
+ {
36
+ name: 'zenOnMount',
37
+ kind: 'function',
38
+ description: 'Called when component is mounted to the DOM.',
39
+ signature: 'zenOnMount(callback: () => void | (() => void)): void'
40
+ },
41
+ {
42
+ name: 'zenOnDestroy',
43
+ kind: 'function',
44
+ description: 'Called when component is removed from the DOM.',
45
+ signature: 'zenOnDestroy(callback: () => void): void'
46
+ },
47
+ {
48
+ name: 'zenOnUpdate',
49
+ kind: 'function',
50
+ description: 'Called after any state update causes a re-render.',
51
+ signature: 'zenOnUpdate(callback: () => void): void'
52
+ },
53
+ {
54
+ name: 'zenRef',
55
+ kind: 'function',
56
+ description: 'Create a reactive reference.',
57
+ signature: 'zenRef<T>(initial: T): { value: T }'
58
+ },
59
+ {
60
+ name: 'zenState',
61
+ kind: 'function',
62
+ description: 'Create reactive state.',
63
+ signature: 'zenState<T>(initial: T): [T, (value: T) => void]'
64
+ },
65
+ {
66
+ name: 'zenMemo',
67
+ kind: 'function',
68
+ description: 'Memoize a computed value.',
69
+ signature: 'zenMemo<T>(compute: () => T): T'
70
+ },
71
+ {
72
+ name: 'zenBatch',
73
+ kind: 'function',
74
+ description: 'Batch multiple state updates.',
75
+ signature: 'zenBatch(callback: () => void): void'
76
+ },
77
+ {
78
+ name: 'zenUntrack',
79
+ kind: 'function',
80
+ description: 'Run code without tracking dependencies.',
81
+ signature: 'zenUntrack<T>(callback: () => T): T'
82
+ }
83
+ ]
84
+ },
85
+ 'zenith/router': {
86
+ module: 'zenith/router',
87
+ description: 'File-based SPA router for Zenith framework.',
88
+ exports: [
89
+ {
90
+ name: 'ZenLink',
91
+ kind: 'component',
92
+ description: 'Declarative navigation component for routes.',
93
+ signature: '<ZenLink to="/path" preload?>{children}</ZenLink>'
94
+ },
95
+ {
96
+ name: 'useRoute',
97
+ kind: 'function',
98
+ description: 'Provides reactive access to the current route. Must be called at top-level script scope.',
99
+ signature: 'useRoute(): { path: string; params: Record<string, string>; query: Record<string, string> }'
100
+ },
101
+ {
102
+ name: 'useRouter',
103
+ kind: 'function',
104
+ description: 'Provides programmatic navigation methods.',
105
+ signature: 'useRouter(): { navigate: (to: string, options?: { replace?: boolean }) => void; back: () => void; forward: () => void }'
106
+ },
107
+ {
108
+ name: 'navigate',
109
+ kind: 'function',
110
+ description: 'Navigate to a route programmatically.',
111
+ signature: 'navigate(to: string, options?: { replace?: boolean }): void'
112
+ },
113
+ {
114
+ name: 'prefetch',
115
+ kind: 'function',
116
+ description: 'Prefetch a route for faster navigation.',
117
+ signature: 'prefetch(path: string): Promise<void>'
118
+ },
119
+ {
120
+ name: 'isActive',
121
+ kind: 'function',
122
+ description: 'Check if a route is currently active.',
123
+ signature: 'isActive(path: string, exact?: boolean): boolean'
124
+ },
125
+ {
126
+ name: 'getRoute',
127
+ kind: 'function',
128
+ description: 'Get the current route state.',
129
+ signature: 'getRoute(): { path: string; params: Record<string, string>; query: Record<string, string> }'
130
+ }
131
+ ]
132
+ }
133
+ };
134
+
135
+ /**
136
+ * Get a core module by name
137
+ */
138
+ export function getCoreModule(moduleName: string): CoreModuleMetadata | undefined {
139
+ return CORE_MODULES[moduleName];
140
+ }
141
+
142
+ /**
143
+ * Get all core module names
144
+ */
145
+ export function getCoreModuleNames(): string[] {
146
+ return Object.keys(CORE_MODULES);
147
+ }
148
+
149
+ /**
150
+ * Get an export from a core module
151
+ */
152
+ export function getCoreExport(moduleName: string, exportName: string): ModuleExport | undefined {
153
+ const module = CORE_MODULES[moduleName];
154
+ if (!module) return undefined;
155
+ return module.exports.find(e => e.name === exportName);
156
+ }
157
+
158
+ /**
159
+ * Check if a module is a core Zenith module
160
+ */
161
+ export function isCoreModule(moduleName: string): boolean {
162
+ return moduleName in CORE_MODULES;
163
+ }