@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/.github/workflows/release.yml +254 -0
- package/.releaserc.json +73 -0
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/bun.lock +83 -0
- package/package.json +34 -0
- package/scripts/release.ts +554 -0
- package/src/diagnostics.ts +260 -0
- package/src/imports.ts +207 -0
- package/src/metadata/core-imports.ts +163 -0
- package/src/metadata/directive-metadata.ts +109 -0
- package/src/metadata/plugin-imports.ts +116 -0
- package/src/project.ts +167 -0
- package/src/router.ts +180 -0
- package/src/server.ts +841 -0
- package/test/fixtures/content-plugin.zen +77 -0
- package/test/fixtures/core-only.zen +59 -0
- package/test/fixtures/no-plugins.zen +115 -0
- package/test/fixtures/router-enabled.zen +76 -0
- package/tsconfig.json +25 -0
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();
|