@zubra/language-server 0.0.0-stage → 5.0.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/LICENSE +15 -0
- package/analysis.js +186 -0
- package/backend.js +63 -0
- package/package.json +32 -4
- package/server.js +122 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Mikey Nichols
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/analysis.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// What the language server knows about a `.zb` document, as plain functions (no LSP types
|
|
2
|
+
// beyond positions), so they can be tested directly.
|
|
3
|
+
|
|
4
|
+
/** The directives, with their attributes. */
|
|
5
|
+
export const DIRECTIVES = {
|
|
6
|
+
'zb-if': { description: 'Render the content when `test` is truthy; an optional final `<zb-else>` otherwise.', attributes: { test: 'The condition: `test={…}`.' } },
|
|
7
|
+
'zb-else': { description: 'The content of an `<zb-if>` when its test is falsy. Must be its last child.', attributes: {} },
|
|
8
|
+
'zb-each': {
|
|
9
|
+
description: 'Render the content once per item of `items`. `let:name={…}` names a value per row.',
|
|
10
|
+
attributes: { items: 'The array: `items={…}`.', as: 'The name of each item.', index: 'The name of the index (optional).', key: 'A unique string or number per item, so rows follow their items: `key={item.id}`.' },
|
|
11
|
+
},
|
|
12
|
+
'zb-slot': { description: 'Where content given to the element goes; the children are the fallback.', attributes: { name: 'The slot name (omit for the default slot).' } },
|
|
13
|
+
'zb-await': { description: 'Show `<zb-pending>` while `value` (a promise, a `resource()`, or an array of them) is pending, then `<zb-then>` or `<zb-catch>`. `<zb-await {user}>` is short for `value={user}`.', attributes: { value: 'The promise, resource, or array of them: `value={…}`.', delay: 'Show the pending content only after this many milliseconds: `delay={200}`.', keep: 'Keep the settled content while a resource reloads; the host gets `:state(loading)`.' } },
|
|
14
|
+
'zb-switch': { description: 'Render the first `<zb-case>` whose `is` matches `value`, else the final `<zb-else>`.', attributes: { value: 'The value to match: `value={…}`.' } },
|
|
15
|
+
'zb-case': { description: 'Inside `<zb-switch>`: rendered when `is` equals the value (`is="paid"`), or is an array that contains it (`is={["shipped", "delivered"]}`).', attributes: { is: 'A string (`is="paid"`), a value (`is={…}`) or an array of them.' } },
|
|
16
|
+
'zb-pending': { description: 'The content of a `<zb-await>` while its value is pending. Comes first.', attributes: {} },
|
|
17
|
+
'zb-then': { description: 'The content of a `<zb-await>` once its value has resolved.', attributes: { as: 'The name of the resolved value, or a pattern: `as="{ name }"`, `as="[user, posts]"`.' } },
|
|
18
|
+
'zb-catch': { description: 'The content of a `<zb-await>` if its value rejected. `retry()` runs the value again.', attributes: { as: 'The name of the error, or a pattern: `as="{ message }"`.' } },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Line/character → offset. */
|
|
22
|
+
export function offsetAt(text, { line, character }) {
|
|
23
|
+
let offset = 0;
|
|
24
|
+
for (let i = 0; i < line; i++) {
|
|
25
|
+
const next = text.indexOf('\n', offset);
|
|
26
|
+
if (next < 0) return text.length;
|
|
27
|
+
offset = next + 1;
|
|
28
|
+
}
|
|
29
|
+
return Math.min(offset + character, text.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function positionAt(text, offset) {
|
|
33
|
+
const before = text.slice(0, offset);
|
|
34
|
+
const line = before.split('\n').length - 1;
|
|
35
|
+
return { line, character: offset - (before.lastIndexOf('\n') + 1) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where the cursor is: in the script or style, in a tag name, in a start tag's attributes, or in the template. */
|
|
39
|
+
export function contextAt(text, offset) {
|
|
40
|
+
const before = text.slice(0, offset);
|
|
41
|
+
const lower = before.toLowerCase();
|
|
42
|
+
for (const block of ['script', 'style']) {
|
|
43
|
+
const open = lower.lastIndexOf(`<${block}`);
|
|
44
|
+
if (open >= 0 && lower.indexOf(`</${block}`, open) < 0) return { kind: block };
|
|
45
|
+
}
|
|
46
|
+
const lt = before.lastIndexOf('<');
|
|
47
|
+
const gt = before.lastIndexOf('>');
|
|
48
|
+
const brace = before.lastIndexOf('{');
|
|
49
|
+
const closeBrace = before.lastIndexOf('}');
|
|
50
|
+
if (brace > closeBrace && brace > gt) return { kind: 'expression' };
|
|
51
|
+
if (lt > gt) {
|
|
52
|
+
const inside = before.slice(lt + 1);
|
|
53
|
+
const tag = inside.match(/^\/?[a-zA-Z][\w.-]*/)?.[0] ?? '';
|
|
54
|
+
if (inside === tag && !tag.startsWith('/')) return { kind: 'tag', prefix: tag };
|
|
55
|
+
if (!tag.startsWith('/') && /\s/.test(inside.slice(tag.length, tag.length + 1))) {
|
|
56
|
+
const attribute = inside.match(/([\w:-]*)$/)?.[1] ?? '';
|
|
57
|
+
// Inside a quoted value?
|
|
58
|
+
const quotes = (inside.match(/"/g)?.length ?? 0) % 2;
|
|
59
|
+
if (!quotes) return { kind: 'attribute', tag, prefix: attribute };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { kind: 'template' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The host tag and its props, read from the source (for completions in expressions). */
|
|
66
|
+
export function hostOf(text) {
|
|
67
|
+
const withoutBlocks = text.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, (block) => ' '.repeat(block.length)).replace(/<!--[\s\S]*?-->/g, (comment) => ' '.repeat(comment.length));
|
|
68
|
+
const match = withoutBlocks.match(/<([a-z][\w.]*-[\w.-]*)([^>]*)>/);
|
|
69
|
+
if (!match) return null;
|
|
70
|
+
const props = attributeNames(match[2]).filter((name) => !['shadow', 'form-associated'].includes(name));
|
|
71
|
+
return { tag: match[1], offset: match.index, props: props.map((name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Attribute names in a start tag's attribute text, skipping quoted and `{…}` values. */
|
|
75
|
+
export function attributeNames(text) {
|
|
76
|
+
const names = [];
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < text.length) {
|
|
79
|
+
const character = text[i];
|
|
80
|
+
if (character === '"' || character === "'") {
|
|
81
|
+
const end = text.indexOf(character, i + 1);
|
|
82
|
+
i = end < 0 ? text.length : end + 1;
|
|
83
|
+
} else if (character === '{') {
|
|
84
|
+
let depth = 0;
|
|
85
|
+
for (; i < text.length; i++) {
|
|
86
|
+
if (text[i] === '{') depth++;
|
|
87
|
+
else if (text[i] === '}' && --depth === 0) break;
|
|
88
|
+
}
|
|
89
|
+
i++;
|
|
90
|
+
} else if (/[a-zA-Z]/.test(character)) {
|
|
91
|
+
const name = text.slice(i).match(/^[a-zA-Z][\w:-]*/)[0];
|
|
92
|
+
names.push(name);
|
|
93
|
+
i += name.length;
|
|
94
|
+
} else {
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return names;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A component's interface from its Custom Elements Manifest. */
|
|
102
|
+
export function interfaceOf(manifest) {
|
|
103
|
+
const element = manifest?.modules?.[0]?.declarations?.find((declaration) => declaration.customElement);
|
|
104
|
+
if (!element) return null;
|
|
105
|
+
return {
|
|
106
|
+
tag: element.tagName,
|
|
107
|
+
description: element.description ?? '',
|
|
108
|
+
attributes: (element.attributes ?? []).map((attribute) => ({ name: attribute.name, type: attribute.type?.text ?? 'unknown', default: attribute.default })),
|
|
109
|
+
events: (element.events ?? []).map((event) => ({ name: event.name, type: event.type?.text ?? 'CustomEvent', description: event.description ?? '' })),
|
|
110
|
+
slots: (element.slots ?? []).map((slot) => slot.name),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Markdown documentation for a component. */
|
|
115
|
+
export function documentation(component) {
|
|
116
|
+
const lines = [`**<${component.tag}>**`];
|
|
117
|
+
if (component.description) lines.push('', component.description);
|
|
118
|
+
if (component.attributes.length) {
|
|
119
|
+
lines.push('', '| Prop | Type | Default |', '|---|---|---|');
|
|
120
|
+
for (const attribute of component.attributes) lines.push(`| \`${attribute.name}\` | \`${attribute.type}\` | ${attribute.default === undefined ? 'required' : `\`${attribute.default}\``} |`);
|
|
121
|
+
}
|
|
122
|
+
if (component.events.length) {
|
|
123
|
+
lines.push('', 'Events: ' + component.events.map((event) => `\`${event.name}\` (\`${event.type}\`)`).join(', '));
|
|
124
|
+
}
|
|
125
|
+
if (component.slots.length) lines.push('', 'Slots: ' + component.slots.map((name) => (name ? `\`${name}\`` : 'default')).join(', '));
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The conditions an element can carry as attributes (4.1). */
|
|
130
|
+
export const CONDITIONS = {
|
|
131
|
+
'zb-if': 'Render this element only when the condition is truthy: `zb-if={…}`.',
|
|
132
|
+
'zb-else-if': 'Render this element when the `zb-if` (or `zb-else-if`) sibling just before it didn\'t, and this condition is truthy.',
|
|
133
|
+
'zb-else': 'Render this element when none of the `zb-if` and `zb-else-if` siblings just before it did.',
|
|
134
|
+
};
|
|
135
|
+
const conditionItems = (prefix) => Object.entries(CONDITIONS)
|
|
136
|
+
.filter(([name]) => name.startsWith(prefix))
|
|
137
|
+
.map(([name, description]) => ({ label: name, kind: 'attribute', documentation: description, insertText: name === 'zb-else' ? name : `${name}={$1}`, snippet: name !== 'zb-else' }));
|
|
138
|
+
|
|
139
|
+
/** Completion items (label, kind, detail, documentation, insertText) at `offset`. */
|
|
140
|
+
export function completions(text, offset, components) {
|
|
141
|
+
const context = contextAt(text, offset);
|
|
142
|
+
if (context.kind === 'tag') {
|
|
143
|
+
return [
|
|
144
|
+
...Object.entries(DIRECTIVES).map(([name, directive]) => ({ label: name, kind: 'directive', documentation: directive.description })),
|
|
145
|
+
...components.map((component) => ({ label: component.tag, kind: 'component', documentation: documentation(component) })),
|
|
146
|
+
].filter((item) => item.label.startsWith(context.prefix));
|
|
147
|
+
}
|
|
148
|
+
if (context.kind === 'attribute') {
|
|
149
|
+
const directive = DIRECTIVES[context.tag];
|
|
150
|
+
if (directive) {
|
|
151
|
+
return Object.entries(directive.attributes).map(([name, description]) => ({
|
|
152
|
+
label: name, kind: 'attribute', documentation: description, insertText: name === 'keep' ? 'keep' : ['as', 'index', 'name'].includes(name) ? `${name}="$1"` : `${name}={$1}`, snippet: true,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
const component = components.find((candidate) => candidate.tag === context.tag);
|
|
156
|
+
if (!component) return conditionItems(context.prefix);
|
|
157
|
+
return [
|
|
158
|
+
...component.attributes.map((attribute) => ({
|
|
159
|
+
label: attribute.name, kind: 'attribute', detail: attribute.type,
|
|
160
|
+
documentation: attribute.default === undefined ? 'Required.' : `Default: \`${attribute.default}\`.`,
|
|
161
|
+
insertText: `${attribute.name}={$1}`, snippet: true,
|
|
162
|
+
})),
|
|
163
|
+
...component.events.map((event) => ({
|
|
164
|
+
label: `on${event.name}`, kind: 'event', detail: event.type, documentation: event.description,
|
|
165
|
+
insertText: `on${event.name}={$1}`, snippet: true,
|
|
166
|
+
})),
|
|
167
|
+
...conditionItems(''),
|
|
168
|
+
].filter((item) => item.label.startsWith(context.prefix));
|
|
169
|
+
}
|
|
170
|
+
if (context.kind === 'expression') {
|
|
171
|
+
return (hostOf(text)?.props ?? []).map((name) => ({ label: name, kind: 'variable', detail: 'prop' }));
|
|
172
|
+
}
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The tag name under `offset`, with its range. */
|
|
177
|
+
export function tagAt(text, offset) {
|
|
178
|
+
const start = text.lastIndexOf('<', offset);
|
|
179
|
+
if (start < 0) return null;
|
|
180
|
+
const match = text.slice(start).match(/^<\/?([a-zA-Z][\w.-]*)/);
|
|
181
|
+
if (!match) return null;
|
|
182
|
+
const nameStart = start + match[0].length - match[1].length;
|
|
183
|
+
const nameEnd = nameStart + match[1].length;
|
|
184
|
+
if (offset < nameStart || offset > nameEnd) return null;
|
|
185
|
+
return { name: match[1], start: nameStart, end: nameEnd };
|
|
186
|
+
}
|
package/backend.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// How the language server reaches the compiler: the WebAssembly build (@zubra/compiler) when it's
|
|
2
|
+
// installed, otherwise the `zubra` CLI (or the one in `compiler`). Both give the same diagnostics.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export async function createBackend({ compiler } = {}) {
|
|
8
|
+
if (!compiler) {
|
|
9
|
+
try {
|
|
10
|
+
const wasm = await import('@zubra/compiler');
|
|
11
|
+
if (wasm.available()) return wasmBackend(wasm);
|
|
12
|
+
} catch {
|
|
13
|
+
// Not installed.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const chosen = compiler ?? process.env.ZUBRA_COMPILER;
|
|
17
|
+
if (chosen) return cliBackend(chosen);
|
|
18
|
+
return cliBackend('zubra');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function wasmBackend(wasm) {
|
|
22
|
+
return {
|
|
23
|
+
name: 'wasm',
|
|
24
|
+
check(path, text) {
|
|
25
|
+
const files = wasm.sources(path);
|
|
26
|
+
files[resolve(path)] = text;
|
|
27
|
+
// Imports of the unsaved text, too.
|
|
28
|
+
for (const specifier of importsOf(text)) wasm.sources(resolve(dirname(path), specifier), files);
|
|
29
|
+
return wasm.request({ command: 'check', path: resolve(path), files }).diagnostics;
|
|
30
|
+
},
|
|
31
|
+
manifest(path) {
|
|
32
|
+
const response = wasm.compileFile(path, { emit: 'manifest' });
|
|
33
|
+
return response.ok ? JSON.parse(response.output) : null;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cliBackend(compiler) {
|
|
39
|
+
const run = (args, input) => spawnSync(compiler, args, { encoding: 'utf8', input });
|
|
40
|
+
return {
|
|
41
|
+
name: 'cli',
|
|
42
|
+
check(path, text) {
|
|
43
|
+
const result = run(['check', path, '--stdin', '--format', 'json'], text);
|
|
44
|
+
if (result.error) return [{ file: path, line: 1, column: 1, severity: 'error', message: `zubra: couldn't run the compiler \`${compiler}\` (${result.error.code ?? result.error.message}); install @zubra/compiler or the zubra CLI`, help: null }];
|
|
45
|
+
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
46
|
+
},
|
|
47
|
+
manifest(path) {
|
|
48
|
+
const result = run(['compile', path, '--emit', 'manifest']);
|
|
49
|
+
if (result.status !== 0) return null;
|
|
50
|
+
try { return JSON.parse(result.stdout); } catch { return null; }
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `.zb` files a component's script imports (relative specifiers). */
|
|
56
|
+
export function importsOf(text) {
|
|
57
|
+
const script = text.match(/<script[^>]*>([\s\S]*?)<\/script>/i)?.[1] ?? '';
|
|
58
|
+
return [...script.matchAll(/import\s+(?:[^'"]*?from\s+)?['"](\.{1,2}\/[^'"]+\.zb)['"]/g)].map((match) => match[1]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const readText = (path) => {
|
|
62
|
+
try { return readFileSync(path, 'utf8'); } catch { return null; }
|
|
63
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/language-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.2",
|
|
4
|
+
"description": "Language server for .zb components: diagnostics, completion, hover and go to definition",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
9
|
+
"directory": "editors/language-server"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"zubra-language-server": "server.js"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./server.js",
|
|
17
|
+
"./analysis": "./analysis.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"server.js",
|
|
21
|
+
"analysis.js",
|
|
22
|
+
"backend.js"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test \"test/*.test.js\""
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"vscode-languageserver": "11.0.0",
|
|
32
|
+
"vscode-languageserver-textdocument": "1.0.16"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// zubra language server: diagnostics from the compiler as you type, completion of directives,
|
|
3
|
+
// imported components, their props and events, hover documentation from their manifests, and
|
|
4
|
+
// go to definition for imported components. Speaks LSP over stdio (`--stdio`).
|
|
5
|
+
//
|
|
6
|
+
// initializationOptions: { compiler?: "/path/to/zubra" } — otherwise @zubra/compiler (WebAssembly)
|
|
7
|
+
// when installed, else `zubra` on PATH.
|
|
8
|
+
import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind, DiagnosticSeverity, CompletionItemKind, InsertTextFormat, MarkupKind } from 'vscode-languageserver/node';
|
|
9
|
+
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
12
|
+
import { statSync } from 'node:fs';
|
|
13
|
+
import { createBackend, importsOf, readText } from './backend.js';
|
|
14
|
+
import { completions, documentation, interfaceOf, offsetAt, positionAt, tagAt } from './analysis.js';
|
|
15
|
+
|
|
16
|
+
const connection = createConnection(ProposedFeatures.all);
|
|
17
|
+
const documents = new TextDocuments(TextDocument);
|
|
18
|
+
let backend;
|
|
19
|
+
const manifests = new Map(); // path → { mtime, component }
|
|
20
|
+
|
|
21
|
+
connection.onInitialize(async (params) => {
|
|
22
|
+
backend = await createBackend(params.initializationOptions ?? {});
|
|
23
|
+
return {
|
|
24
|
+
capabilities: {
|
|
25
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
26
|
+
completionProvider: { triggerCharacters: ['<', ' ', '{'] },
|
|
27
|
+
hoverProvider: true,
|
|
28
|
+
definitionProvider: true,
|
|
29
|
+
},
|
|
30
|
+
serverInfo: { name: 'zubra-language-server', version: '1.2.0' },
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const pathOf = (uri) => fileURLToPath(uri);
|
|
35
|
+
|
|
36
|
+
/** The components a document imports: { tag, …interface, path }. */
|
|
37
|
+
function componentsOf(uri, text) {
|
|
38
|
+
const base = dirname(pathOf(uri));
|
|
39
|
+
const found = [];
|
|
40
|
+
for (const specifier of importsOf(text)) {
|
|
41
|
+
const path = resolve(base, specifier);
|
|
42
|
+
let mtime;
|
|
43
|
+
try { mtime = statSync(path).mtimeMs; } catch { continue; }
|
|
44
|
+
let cached = manifests.get(path);
|
|
45
|
+
if (!cached || cached.mtime !== mtime) {
|
|
46
|
+
cached = { mtime, component: interfaceOf(backend.manifest(path)) };
|
|
47
|
+
manifests.set(path, cached);
|
|
48
|
+
}
|
|
49
|
+
if (cached.component) found.push({ ...cached.component, path });
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const timers = new Map();
|
|
55
|
+
function validate(document) {
|
|
56
|
+
clearTimeout(timers.get(document.uri));
|
|
57
|
+
timers.set(document.uri, setTimeout(() => {
|
|
58
|
+
const path = pathOf(document.uri);
|
|
59
|
+
const text = document.getText();
|
|
60
|
+
const diagnostics = backend.check(path, text)
|
|
61
|
+
.filter((diagnostic) => resolve(diagnostic.file) === path || diagnostic.file === path)
|
|
62
|
+
.map((diagnostic) => {
|
|
63
|
+
const line = Math.max(diagnostic.line - 1, 0);
|
|
64
|
+
const character = Math.max(diagnostic.column - 1, 0);
|
|
65
|
+
const lineText = text.split('\n')[line] ?? '';
|
|
66
|
+
const word = lineText.slice(character).match(/^[\w.$-]+|^./)?.[0] ?? '';
|
|
67
|
+
return {
|
|
68
|
+
range: { start: { line, character }, end: { line, character: character + Math.max(word.length, 1) } },
|
|
69
|
+
severity: diagnostic.severity === 'warning' ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error,
|
|
70
|
+
source: 'zubra',
|
|
71
|
+
message: diagnostic.help ? `${diagnostic.message}\n${diagnostic.help}` : diagnostic.message,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
connection.sendDiagnostics({ uri: document.uri, diagnostics });
|
|
75
|
+
}, 100));
|
|
76
|
+
}
|
|
77
|
+
documents.onDidChangeContent((change) => validate(change.document));
|
|
78
|
+
documents.onDidClose((event) => connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }));
|
|
79
|
+
|
|
80
|
+
const kinds = { directive: CompletionItemKind.Keyword, component: CompletionItemKind.Class, attribute: CompletionItemKind.Property, event: CompletionItemKind.Event, variable: CompletionItemKind.Variable };
|
|
81
|
+
connection.onCompletion(({ textDocument, position }) => {
|
|
82
|
+
const document = documents.get(textDocument.uri);
|
|
83
|
+
if (!document) return [];
|
|
84
|
+
const text = document.getText();
|
|
85
|
+
return completions(text, offsetAt(text, position), componentsOf(textDocument.uri, text)).map((item) => ({
|
|
86
|
+
label: item.label,
|
|
87
|
+
kind: kinds[item.kind],
|
|
88
|
+
detail: item.detail,
|
|
89
|
+
documentation: item.documentation ? { kind: MarkupKind.Markdown, value: item.documentation } : undefined,
|
|
90
|
+
insertText: item.insertText,
|
|
91
|
+
insertTextFormat: item.snippet ? InsertTextFormat.Snippet : undefined,
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
connection.onHover(({ textDocument, position }) => {
|
|
96
|
+
const document = documents.get(textDocument.uri);
|
|
97
|
+
if (!document) return null;
|
|
98
|
+
const text = document.getText();
|
|
99
|
+
const tag = tagAt(text, offsetAt(text, position));
|
|
100
|
+
const component = tag && componentsOf(textDocument.uri, text).find((candidate) => candidate.tag === tag.name);
|
|
101
|
+
if (!component) return null;
|
|
102
|
+
return {
|
|
103
|
+
contents: { kind: MarkupKind.Markdown, value: documentation(component) },
|
|
104
|
+
range: { start: positionAt(text, tag.start), end: positionAt(text, tag.end) },
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
connection.onDefinition(({ textDocument, position }) => {
|
|
109
|
+
const document = documents.get(textDocument.uri);
|
|
110
|
+
if (!document) return null;
|
|
111
|
+
const text = document.getText();
|
|
112
|
+
const tag = tagAt(text, offsetAt(text, position));
|
|
113
|
+
const component = tag && componentsOf(textDocument.uri, text).find((candidate) => candidate.tag === tag.name);
|
|
114
|
+
if (!component) return null;
|
|
115
|
+
const target = readText(component.path) ?? '';
|
|
116
|
+
const at = Math.max(target.search(new RegExp(`<${component.tag}[\\s>]`)), 0);
|
|
117
|
+
const start = positionAt(target, at);
|
|
118
|
+
return { uri: pathToFileURL(component.path).href, range: { start, end: { line: start.line, character: start.character + component.tag.length + 1 } } };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
documents.listen(connection);
|
|
122
|
+
connection.listen();
|
package/README.md
DELETED