spark-html-language-server 0.1.0
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/README.md +63 -0
- package/bin/cli.js +5 -0
- package/package.json +40 -0
- package/src/analyze.js +488 -0
- package/src/docs.js +161 -0
- package/src/index.d.ts +46 -0
- package/src/index.js +3 -0
- package/src/server.js +343 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# spark-html-language-server
|
|
2
|
+
|
|
3
|
+
Language server (LSP) for [Spark](https://github.com/wilkinnovo/spark) single-file
|
|
4
|
+
`.html` components. Zero dependencies, speaks LSP over stdio.
|
|
5
|
+
|
|
6
|
+
## What you get
|
|
7
|
+
|
|
8
|
+
- **Diagnostics**, live as you type:
|
|
9
|
+
- `{binding}` used in the template but never declared in `<script>`
|
|
10
|
+
- JS imports that are never used
|
|
11
|
+
- script syntax errors (the exact code the runtime would execute)
|
|
12
|
+
- `<div import="…">` pointing at a component file that doesn't exist
|
|
13
|
+
- `each` without `key=` (hint — keyed reconciliation is opt-in)
|
|
14
|
+
- malformed `each` expressions
|
|
15
|
+
- **Go-to-definition** — jump from `<div import="components/card">` to
|
|
16
|
+
`card.html`, and from any `{symbol}` to its `let` / `function` / `$:` /
|
|
17
|
+
`export let` declaration.
|
|
18
|
+
- **Autocomplete**
|
|
19
|
+
- props on import placeholders, read from the target component's
|
|
20
|
+
`export let` declarations
|
|
21
|
+
- every template directive (`each`, `if`/`else-if`/`else`, `await`/`then`/`catch`,
|
|
22
|
+
`bind:value|checked|group|form`, `:hidden`-style dynamic attributes, `key`,
|
|
23
|
+
`route`, `transition:fade|slide|scale`, `spark-ignore`)
|
|
24
|
+
- script symbols and Spark builtins (`useStore`, `onMount`, `props`) inside
|
|
25
|
+
`{…}` and `<script>`
|
|
26
|
+
- **Hover docs** for every directive and declaration.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g spark-html-language-server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The `spark-html-language-server` binary starts the server on stdio — the
|
|
35
|
+
transport every LSP client speaks.
|
|
36
|
+
|
|
37
|
+
## VS Code
|
|
38
|
+
|
|
39
|
+
Install the **Spark (spark-html)** extension from
|
|
40
|
+
[`editors/vscode`](https://github.com/wilkinnovo/spark/tree/main/editors/vscode) —
|
|
41
|
+
it bundles syntax highlighting and launches this server automatically (globally
|
|
42
|
+
installed binary, or `node_modules/.bin` in your project).
|
|
43
|
+
|
|
44
|
+
## Programmatic use
|
|
45
|
+
|
|
46
|
+
The analyzer is exported for tooling:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { analyze } from 'spark-html-language-server';
|
|
50
|
+
|
|
51
|
+
const { declarations, props, diagnostics } = analyze(componentSource);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Scope (v0.x)
|
|
55
|
+
|
|
56
|
+
The server analyzes one component at a time — the same boundary the runtime
|
|
57
|
+
has. It does not type-check across files (props completion reads the target
|
|
58
|
+
file's `export let` names, not their types), and remote URL imports are not
|
|
59
|
+
resolved.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
package/bin/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spark-html-language-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Language server (LSP) for spark-html single-file components — diagnostics (undefined bindings, unused imports, script errors, missing key), go-to-definition for component imports and symbols, prop autocomplete from export let, and hover docs for every directive. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"homepage": "https://wilkinnovo.github.io/spark",
|
|
9
|
+
"bin": {
|
|
10
|
+
"spark-html-language-server": "bin/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.d.ts",
|
|
15
|
+
"default": "./src/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node test/lsp.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"bin"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/wilkinnovo/spark.git",
|
|
28
|
+
"directory": "packages/spark-html-language-server"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"spark-html",
|
|
32
|
+
"lsp",
|
|
33
|
+
"language-server",
|
|
34
|
+
"ide",
|
|
35
|
+
"vscode",
|
|
36
|
+
"diagnostics",
|
|
37
|
+
"autocomplete"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT"
|
|
40
|
+
}
|
package/src/analyze.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-html-language-server — component analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Parses a single-file Spark component (.html) the same way the runtime does
|
|
5
|
+
* (regex + string scanning, no AST) and produces everything the LSP features
|
|
6
|
+
* need: declarations with offsets, template references, import placeholders,
|
|
7
|
+
* each/await scopes, and diagnostics.
|
|
8
|
+
*
|
|
9
|
+
* All positions are absolute character offsets into the original text; the
|
|
10
|
+
* server converts them to LSP line/character positions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Identifiers never flagged as "undefined" — JS/browser globals plus the
|
|
14
|
+
// builtins Spark injects into every component scope (see spark-html/globals).
|
|
15
|
+
export const KNOWN_GLOBALS = new Set([
|
|
16
|
+
// Spark component builtins
|
|
17
|
+
'useStore', 'onMount', 'props', 'await',
|
|
18
|
+
// literals & keywords that the identifier regex can catch
|
|
19
|
+
'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
|
|
20
|
+
'new', 'typeof', 'instanceof', 'in', 'of', 'this', 'void', 'delete',
|
|
21
|
+
'if', 'else', 'return', 'function', 'async', 'arguments',
|
|
22
|
+
// ubiquitous browser/JS globals
|
|
23
|
+
'window', 'document', 'console', 'Math', 'JSON', 'Date', 'Number',
|
|
24
|
+
'String', 'Boolean', 'Array', 'Object', 'Promise', 'Map', 'Set',
|
|
25
|
+
'RegExp', 'Error', 'Intl', 'Symbol', 'BigInt', 'Reflect', 'Proxy',
|
|
26
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'structuredClone',
|
|
27
|
+
'fetch', 'localStorage', 'sessionStorage', 'navigator', 'location',
|
|
28
|
+
'history', 'alert', 'confirm', 'prompt', 'crypto', 'performance',
|
|
29
|
+
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
|
30
|
+
'requestAnimationFrame', 'cancelAnimationFrame', 'queueMicrotask',
|
|
31
|
+
'URL', 'URLSearchParams', 'FormData', 'Blob', 'File', 'AbortController',
|
|
32
|
+
'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'Audio', 'Image',
|
|
33
|
+
'WebSocket', 'Notification', 'IntersectionObserver', 'ResizeObserver',
|
|
34
|
+
'MutationObserver', 'matchMedia', 'getComputedStyle', 'globalThis',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const IDENT = /[A-Za-z_$][\w$]*/g;
|
|
38
|
+
|
|
39
|
+
// ── masking helpers ────────────────────────────────────────────────────────
|
|
40
|
+
// Replace comments and string/template-literal contents with spaces so regex
|
|
41
|
+
// scans can't match inside them — same length, so offsets stay valid.
|
|
42
|
+
|
|
43
|
+
export function maskJs(code) {
|
|
44
|
+
let out = '';
|
|
45
|
+
let i = 0;
|
|
46
|
+
const n = code.length;
|
|
47
|
+
while (i < n) {
|
|
48
|
+
const c = code[i];
|
|
49
|
+
const two = code.slice(i, i + 2);
|
|
50
|
+
if (two === '//') {
|
|
51
|
+
const end = code.indexOf('\n', i);
|
|
52
|
+
const stop = end === -1 ? n : end;
|
|
53
|
+
out += ' '.repeat(stop - i);
|
|
54
|
+
i = stop;
|
|
55
|
+
} else if (two === '/*') {
|
|
56
|
+
const end = code.indexOf('*/', i + 2);
|
|
57
|
+
const stop = end === -1 ? n : end + 2;
|
|
58
|
+
out += ' '.repeat(stop - i);
|
|
59
|
+
i = stop;
|
|
60
|
+
} else if (c === "'" || c === '"' || c === '`') {
|
|
61
|
+
let j = i + 1;
|
|
62
|
+
while (j < n) {
|
|
63
|
+
if (code[j] === '\\') { j += 2; continue; }
|
|
64
|
+
if (code[j] === c) break;
|
|
65
|
+
j++;
|
|
66
|
+
}
|
|
67
|
+
const stop = Math.min(j + 1, n);
|
|
68
|
+
out += c + ' '.repeat(Math.max(0, stop - i - 2)) + (stop - i >= 2 ? c : '');
|
|
69
|
+
i = stop;
|
|
70
|
+
} else {
|
|
71
|
+
out += c;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Index of the `}` matching the `{` at openIdx (quote-aware), or -1.
|
|
79
|
+
function matchingBrace(text, openIdx) {
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let quote = null;
|
|
82
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
83
|
+
const c = text[i];
|
|
84
|
+
if (quote) {
|
|
85
|
+
if (c === '\\') i++;
|
|
86
|
+
else if (c === quote) quote = null;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (c === "'" || c === '"' || c === '`') quote = c;
|
|
90
|
+
else if (c === '{') depth++;
|
|
91
|
+
else if (c === '}' && --depth === 0) return i;
|
|
92
|
+
}
|
|
93
|
+
return -1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── script extraction ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function findScript(text) {
|
|
99
|
+
// The component's script is the first <script> without src=.
|
|
100
|
+
const re = /<script\b([^>]*)>/gi;
|
|
101
|
+
let m;
|
|
102
|
+
while ((m = re.exec(text)) !== null) {
|
|
103
|
+
if (/\bsrc\s*=/i.test(m[1])) continue;
|
|
104
|
+
const start = m.index + m[0].length;
|
|
105
|
+
const end = text.indexOf('</script', start);
|
|
106
|
+
return { start, end: end === -1 ? text.length : end, attrs: m[1] };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function blankRange(chars, start, end) {
|
|
112
|
+
for (let i = start; i < end && i < chars.length; i++) {
|
|
113
|
+
if (chars[i] !== '\n') chars[i] = ' ';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Template text = source minus <script>/<style> contents minus spark-ignore
|
|
118
|
+
// subtrees (never patched by Spark, so never analyzed).
|
|
119
|
+
function templateMask(text, script) {
|
|
120
|
+
const chars = [...text];
|
|
121
|
+
if (script) blankRange(chars, script.start - 8, script.end); // include "<script>" tag itself
|
|
122
|
+
const styleRe = /<style\b[^>]*>/gi;
|
|
123
|
+
let m;
|
|
124
|
+
while ((m = styleRe.exec(text)) !== null) {
|
|
125
|
+
const close = text.indexOf('</style', m.index);
|
|
126
|
+
blankRange(chars, m.index, close === -1 ? text.length : close + 8);
|
|
127
|
+
}
|
|
128
|
+
// spark-ignore: blank from the opening tag to the matching close tag.
|
|
129
|
+
const ignoreRe = /<([a-zA-Z][\w-]*)\b[^>]*\bspark-ignore[\s>=/]/g;
|
|
130
|
+
while ((m = ignoreRe.exec(text)) !== null) {
|
|
131
|
+
const tag = m[1].toLowerCase();
|
|
132
|
+
let depth = 1;
|
|
133
|
+
const tagRe = new RegExp(`</?${tag}\\b`, 'gi');
|
|
134
|
+
tagRe.lastIndex = m.index + m[0].length;
|
|
135
|
+
let end = text.length;
|
|
136
|
+
let t;
|
|
137
|
+
while ((t = tagRe.exec(text)) !== null) {
|
|
138
|
+
depth += t[0][1] === '/' ? -1 : 1;
|
|
139
|
+
if (depth === 0) { end = text.indexOf('>', t.index) + 1 || text.length; break; }
|
|
140
|
+
}
|
|
141
|
+
blankRange(chars, m.index, end);
|
|
142
|
+
}
|
|
143
|
+
return chars.join('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Content range of the <template …> whose open tag starts at tagIdx.
|
|
147
|
+
function templateContentRange(text, tagIdx) {
|
|
148
|
+
const open = text.indexOf('>', tagIdx);
|
|
149
|
+
if (open === -1) return null;
|
|
150
|
+
let depth = 1;
|
|
151
|
+
const re = /<\/?template\b/gi;
|
|
152
|
+
re.lastIndex = open;
|
|
153
|
+
let m;
|
|
154
|
+
while ((m = re.exec(text)) !== null) {
|
|
155
|
+
depth += m[0][1] === '/' ? -1 : 1;
|
|
156
|
+
if (depth === 0) return { start: open + 1, end: m.index };
|
|
157
|
+
}
|
|
158
|
+
return { start: open + 1, end: text.length };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── expression scanning ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
// Identifiers referenced by a JS expression (masked for strings), each with
|
|
164
|
+
// its absolute offset. Skips property accesses (`.foo`, `?.foo`), object keys
|
|
165
|
+
// (`{ foo: … }`), and locals declared by arrow params inside the expression.
|
|
166
|
+
export function exprRefs(expr, baseOffset) {
|
|
167
|
+
const masked = maskJs(expr);
|
|
168
|
+
const locals = new Set();
|
|
169
|
+
// arrow params: `e =>` and `(a, b) =>`
|
|
170
|
+
for (const m of masked.matchAll(/(?:\(([^)]*)\)|([A-Za-z_$][\w$]*))\s*=>/g)) {
|
|
171
|
+
for (const p of (m[1] ?? m[2] ?? '').split(',')) {
|
|
172
|
+
const name = p.trim().match(/^[A-Za-z_$][\w$]*/);
|
|
173
|
+
if (name) locals.add(name[0]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const refs = [];
|
|
177
|
+
let m;
|
|
178
|
+
IDENT.lastIndex = 0;
|
|
179
|
+
while ((m = IDENT.exec(masked)) !== null) {
|
|
180
|
+
const name = m[0];
|
|
181
|
+
const before = masked.slice(0, m.index).match(/[\s\S]?$/)[0];
|
|
182
|
+
const prev = masked.slice(0, m.index).replace(/\s+$/, '').slice(-2);
|
|
183
|
+
if (before === '.' || prev.endsWith('.') ) continue; // property access
|
|
184
|
+
const after = masked.slice(m.index + name.length).match(/^\s*./)?.[0]?.trim();
|
|
185
|
+
if (after === ':' && !prev.endsWith('?')) continue; // object key / label
|
|
186
|
+
if (locals.has(name) || KNOWN_GLOBALS.has(name)) continue;
|
|
187
|
+
refs.push({ name, offset: baseOffset + m.index });
|
|
188
|
+
}
|
|
189
|
+
return refs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── script analysis ────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function analyzeScript(text, script) {
|
|
195
|
+
const declarations = new Map(); // name -> { kind, offset }
|
|
196
|
+
const imports = [];
|
|
197
|
+
const diagnostics = [];
|
|
198
|
+
if (!script) return { declarations, imports, diagnostics, refs: [] };
|
|
199
|
+
|
|
200
|
+
const code = text.slice(script.start, script.end);
|
|
201
|
+
const masked = maskJs(code);
|
|
202
|
+
const declare = (name, kind, offset) => {
|
|
203
|
+
if (!declarations.has(name)) declarations.set(name, { kind, offset: script.start + offset });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// JS imports (Spark replays them as dynamic import()).
|
|
207
|
+
const importRe = /(^|[\n;])\s*import\s+(?:([^'"\n;]+?)\s+from\s+)?(['"])([^'"\n]*)\3/g;
|
|
208
|
+
let m;
|
|
209
|
+
while ((m = importRe.exec(masked)) !== null) {
|
|
210
|
+
const clause = m[2] || '';
|
|
211
|
+
const spec = code.slice(m.index, m.index + m[0].length).match(/['"]([^'"]*)['"]/)?.[1] ?? '';
|
|
212
|
+
const entry = { spec, locals: [], start: script.start + m.index, end: script.start + m.index + m[0].length };
|
|
213
|
+
const clauseBase = m.index + m[0].indexOf(clause);
|
|
214
|
+
const named = clause.match(/\{([^}]*)\}/);
|
|
215
|
+
let rest = clause;
|
|
216
|
+
if (named) {
|
|
217
|
+
for (const part of named[1].split(',')) {
|
|
218
|
+
const asM = part.match(/^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/);
|
|
219
|
+
if (!asM) continue;
|
|
220
|
+
const local = asM[2] || asM[1];
|
|
221
|
+
const off = clauseBase + clause.indexOf(local, named.index);
|
|
222
|
+
entry.locals.push({ name: local, offset: script.start + off });
|
|
223
|
+
}
|
|
224
|
+
rest = clause.slice(0, named.index) + clause.slice(named.index + named[0].length);
|
|
225
|
+
}
|
|
226
|
+
const ns = rest.match(/\*\s*as\s+([A-Za-z_$][\w$]*)/);
|
|
227
|
+
if (ns) entry.locals.push({ name: ns[1], offset: script.start + clauseBase + rest.indexOf(ns[1]) });
|
|
228
|
+
const def = rest.replace(/\*\s*as\s+[A-Za-z_$][\w$]*/, '').match(/[A-Za-z_$][\w$]*/);
|
|
229
|
+
if (def) entry.locals.push({ name: def[0], offset: script.start + clauseBase + clause.indexOf(def[0]) });
|
|
230
|
+
for (const l of entry.locals) declare(l.name, 'import', l.offset - script.start + 0);
|
|
231
|
+
for (const l of entry.locals) declarations.get(l.name).offset = l.offset;
|
|
232
|
+
imports.push(entry);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// `export let x` = prop; plain let/const/var = state (comma chains too).
|
|
236
|
+
const declRe = /(^|[\n;{}])\s*(export\s+)?(let|const|var)\s+([A-Za-z_$][\w$]*)/g;
|
|
237
|
+
while ((m = declRe.exec(masked)) !== null) {
|
|
238
|
+
const name = m[4];
|
|
239
|
+
const offset = m.index + m[0].length - name.length;
|
|
240
|
+
declare(name, m[2] ? 'prop' : 'let', offset);
|
|
241
|
+
// comma-chained names at the same nesting depth: `let a = 1, b = 2`
|
|
242
|
+
let i = m.index + m[0].length;
|
|
243
|
+
let depth = 0;
|
|
244
|
+
while (i < masked.length) {
|
|
245
|
+
const c = masked[i];
|
|
246
|
+
if ('([{'.includes(c)) depth++;
|
|
247
|
+
else if (')]}'.includes(c)) { if (--depth < 0) break; }
|
|
248
|
+
else if ((c === ';' || c === '\n') && depth === 0) break;
|
|
249
|
+
else if (c === ',' && depth === 0) {
|
|
250
|
+
const next = masked.slice(i + 1).match(/^\s*([A-Za-z_$][\w$]*)/);
|
|
251
|
+
if (next) declare(next[1], m[2] ? 'prop' : 'let', i + 1 + next[0].length - next[1].length);
|
|
252
|
+
}
|
|
253
|
+
i++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// function declarations
|
|
258
|
+
const funcRe = /(^|[\n;{}])\s*(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g;
|
|
259
|
+
while ((m = funcRe.exec(masked)) !== null) {
|
|
260
|
+
declare(m[2], 'function', m.index + m[0].length - m[2].length);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `$: x = …` implicitly declares x
|
|
264
|
+
const reactiveRe = /(^|\n)\s*\$:\s*([A-Za-z_$][\w$]*)\s*=(?!=)/g;
|
|
265
|
+
while ((m = reactiveRe.exec(masked)) !== null) {
|
|
266
|
+
declare(m[2], 'reactive', m.index + m[0].indexOf(m[2]));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Syntax check: what the runtime would execute. `$:` is a valid JS label;
|
|
270
|
+
// `import`/`export` are not valid inside Function, so strip them the way
|
|
271
|
+
// the runtime lifts them out.
|
|
272
|
+
let checkable = code;
|
|
273
|
+
for (const imp of imports) {
|
|
274
|
+
const s = imp.start - script.start;
|
|
275
|
+
const e = imp.end - script.start;
|
|
276
|
+
checkable = checkable.slice(0, s) + checkable.slice(s, e).replace(/\S/g, ' ') + checkable.slice(e);
|
|
277
|
+
}
|
|
278
|
+
checkable = checkable.replace(/(^|[\n;{}])(\s*)export(\s+)(?=let|const|var)/g, '$1$2 $3');
|
|
279
|
+
try {
|
|
280
|
+
new Function(checkable); // eslint-disable-line no-new-func
|
|
281
|
+
} catch (e) {
|
|
282
|
+
let offset = script.start;
|
|
283
|
+
const loc = e.stack?.match(/<anonymous>:(\d+):(\d+)/);
|
|
284
|
+
if (loc) {
|
|
285
|
+
// new Function() prepends two lines before the body.
|
|
286
|
+
const line = Math.max(0, Number(loc[1]) - 3);
|
|
287
|
+
const lines = code.split('\n');
|
|
288
|
+
offset = script.start + lines.slice(0, line).reduce((s, l) => s + l.length + 1, 0) + Number(loc[2]) - 1;
|
|
289
|
+
}
|
|
290
|
+
diagnostics.push({
|
|
291
|
+
start: offset,
|
|
292
|
+
end: Math.min(offset + 1, script.end),
|
|
293
|
+
severity: 1,
|
|
294
|
+
message: `Script error: ${e.message}`,
|
|
295
|
+
code: 'script-syntax',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// References inside the script (for unused-import detection): identifiers
|
|
300
|
+
// outside each import's own statement.
|
|
301
|
+
const refs = [];
|
|
302
|
+
IDENT.lastIndex = 0;
|
|
303
|
+
while ((m = IDENT.exec(masked)) !== null) {
|
|
304
|
+
const abs = script.start + m.index;
|
|
305
|
+
if (imports.some((imp) => abs >= imp.start && abs < imp.end)) continue;
|
|
306
|
+
if (masked[m.index - 1] === '.') continue;
|
|
307
|
+
refs.push({ name: m[0], offset: abs });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { declarations, imports, diagnostics, refs };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── template analysis ──────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
function analyzeTemplate(text, tpl) {
|
|
316
|
+
const refs = []; // { name, offset, scopes: [names visible here] }
|
|
317
|
+
const eachBlocks = []; // { itemVar, indexVar, arrayExpr, attrOffset, content: {start,end}, hasKey }
|
|
318
|
+
const awaitBlocks = []; // { content: {start,end} }
|
|
319
|
+
const importTags = []; // { path, valueStart, valueEnd, tagStart, tagEnd }
|
|
320
|
+
let m;
|
|
321
|
+
|
|
322
|
+
// <template each="item, i in expr" key="…">
|
|
323
|
+
const eachRe = /<template\b[^>]*\beach\s*=\s*"([^"]*)"/gi;
|
|
324
|
+
while ((m = eachRe.exec(tpl)) !== null) {
|
|
325
|
+
const spec = m[1];
|
|
326
|
+
const specStart = m.index + m[0].length - spec.length - 1;
|
|
327
|
+
const parts = spec.match(/^(\w+)(?:\s*,\s*(\w+))?\s+in\s+([\s\S]+)$/);
|
|
328
|
+
const tagEnd = tpl.indexOf('>', m.index);
|
|
329
|
+
const tag = tpl.slice(m.index, tagEnd === -1 ? tpl.length : tagEnd);
|
|
330
|
+
const content = templateContentRange(tpl, m.index);
|
|
331
|
+
if (!parts) {
|
|
332
|
+
eachBlocks.push({ itemVar: null, indexVar: null, arrayExpr: spec, attrOffset: specStart, content, hasKey: true, malformed: true });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
eachBlocks.push({
|
|
336
|
+
itemVar: parts[1],
|
|
337
|
+
indexVar: parts[2] || null,
|
|
338
|
+
arrayExpr: parts[3].trim(),
|
|
339
|
+
attrOffset: specStart,
|
|
340
|
+
exprOffset: specStart + spec.indexOf(parts[3]),
|
|
341
|
+
content,
|
|
342
|
+
hasKey: /\bkey\s*=\s*"/.test(tag),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// <template await="expr"> — `await` is in scope inside (then/catch included).
|
|
347
|
+
const awaitRe = /<template\b[^>]*\bawait\s*=\s*"([^"]*)"/gi;
|
|
348
|
+
while ((m = awaitRe.exec(tpl)) !== null) {
|
|
349
|
+
const content = templateContentRange(tpl, m.index);
|
|
350
|
+
if (content) awaitBlocks.push({ content });
|
|
351
|
+
let expr = m[1];
|
|
352
|
+
let exprOffset = m.index + m[0].length - m[1].length - 1;
|
|
353
|
+
const once = expr.match(/^once\(([\s\S]*)\)$/);
|
|
354
|
+
if (once) { expr = once[1]; exprOffset += 5; }
|
|
355
|
+
refs.push(...exprRefs(expr, exprOffset));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// {interpolations} — quote-aware matching, `\{` escapes skipped.
|
|
359
|
+
for (let i = 0; i < tpl.length; i++) {
|
|
360
|
+
if (tpl[i] !== '{' || tpl[i - 1] === '\\' || tpl[i - 1] === '$') continue;
|
|
361
|
+
const close = matchingBrace(tpl, i);
|
|
362
|
+
if (close === -1) continue;
|
|
363
|
+
refs.push(...exprRefs(tpl.slice(i + 1, close), i + 1));
|
|
364
|
+
i = close;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// :attr="expr" dynamic attributes + template if/else-if/key
|
|
368
|
+
const dynRe = /(?<=\s):([\w-]+)\s*=\s*"([^"]*)"/g;
|
|
369
|
+
while ((m = dynRe.exec(tpl)) !== null) {
|
|
370
|
+
refs.push(...exprRefs(m[2], m.index + m[0].length - m[2].length - 1));
|
|
371
|
+
}
|
|
372
|
+
const condRe = /<template\b[^>]*\b(if|else-if|key)\s*=\s*"([^"]*)"/gi;
|
|
373
|
+
while ((m = condRe.exec(tpl)) !== null) {
|
|
374
|
+
refs.push(...exprRefs(m[2], m.index + m[0].length - m[2].length - 1));
|
|
375
|
+
}
|
|
376
|
+
// key= on the each tag itself (evaluated per item)
|
|
377
|
+
const keyRe = /<template\b[^>]*\beach\s*=[^>]*\bkey\s*=\s*"([^"]*)"/gi;
|
|
378
|
+
while ((m = keyRe.exec(tpl)) !== null) {
|
|
379
|
+
refs.push(...exprRefs(m[1], m.index + m[0].length - m[1].length - 1));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// bind:x="target" — target must be writable state.
|
|
383
|
+
const bindRe = /(?<=\s)bind:([\w-]+)\s*=\s*"([^"]*)"/g;
|
|
384
|
+
while ((m = bindRe.exec(tpl)) !== null) {
|
|
385
|
+
refs.push(...exprRefs(m[2], m.index + m[0].length - m[2].length - 1));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// <div import="path" …> placeholders.
|
|
389
|
+
const impRe = /<[a-zA-Z][\w-]*\b[^>]*\bimport\s*=\s*"([^"]*)"/g;
|
|
390
|
+
while ((m = impRe.exec(tpl)) !== null) {
|
|
391
|
+
const valueStart = m.index + m[0].length - m[1].length - 1;
|
|
392
|
+
importTags.push({
|
|
393
|
+
path: m[1],
|
|
394
|
+
valueStart,
|
|
395
|
+
valueEnd: valueStart + m[1].length,
|
|
396
|
+
tagStart: m.index,
|
|
397
|
+
tagEnd: tpl.indexOf('>', m.index),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { refs, eachBlocks, awaitBlocks, importTags };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── entry point ────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
export function analyze(text) {
|
|
407
|
+
const script = findScript(text);
|
|
408
|
+
const tpl = templateMask(text, script);
|
|
409
|
+
const s = analyzeScript(text, script);
|
|
410
|
+
const t = analyzeTemplate(text, tpl);
|
|
411
|
+
const diagnostics = [...s.diagnostics];
|
|
412
|
+
|
|
413
|
+
const declared = (name, offset) => {
|
|
414
|
+
if (s.declarations.has(name)) return true;
|
|
415
|
+
for (const b of t.eachBlocks) {
|
|
416
|
+
if (!b.content || offset < b.content.start || offset >= b.content.end) continue;
|
|
417
|
+
if (name === b.itemVar || name === b.indexVar) return true;
|
|
418
|
+
}
|
|
419
|
+
// the each/key expressions on the tag itself also see the loop vars
|
|
420
|
+
for (const b of t.eachBlocks) {
|
|
421
|
+
if (b.content && offset < b.content.start && offset >= b.attrOffset &&
|
|
422
|
+
(name === b.itemVar || name === b.indexVar)) return true;
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Undefined template bindings.
|
|
428
|
+
for (const ref of t.refs) {
|
|
429
|
+
if (!declared(ref.name, ref.offset)) {
|
|
430
|
+
diagnostics.push({
|
|
431
|
+
start: ref.offset,
|
|
432
|
+
end: ref.offset + ref.name.length,
|
|
433
|
+
severity: 2,
|
|
434
|
+
message: `'${ref.name}' is not declared in this component's <script> (no let/function/$:/export let matches).`,
|
|
435
|
+
code: 'undefined-binding',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// each without key= — index-matched patching; keyed is opt-in, so a hint.
|
|
441
|
+
for (const b of t.eachBlocks) {
|
|
442
|
+
if (!b.hasKey && !b.malformed) {
|
|
443
|
+
diagnostics.push({
|
|
444
|
+
start: b.attrOffset,
|
|
445
|
+
end: b.attrOffset + 4,
|
|
446
|
+
severity: 4,
|
|
447
|
+
message: `each without key= — rows are matched by index. Add key="${b.itemVar}.id" (or another stable expression) for keyed reconciliation.`,
|
|
448
|
+
code: 'each-no-key',
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (b.malformed) {
|
|
452
|
+
diagnostics.push({
|
|
453
|
+
start: b.attrOffset,
|
|
454
|
+
end: b.attrOffset + b.arrayExpr.length,
|
|
455
|
+
severity: 1,
|
|
456
|
+
message: `Malformed each — expected "item in items" or "item, i in items".`,
|
|
457
|
+
code: 'each-malformed',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Unused JS imports: local never referenced in script or template.
|
|
463
|
+
const usedNames = new Set([...s.refs.map((r) => r.name), ...t.refs.map((r) => r.name)]);
|
|
464
|
+
for (const imp of s.imports) {
|
|
465
|
+
for (const local of imp.locals) {
|
|
466
|
+
if (!usedNames.has(local.name)) {
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
start: local.offset,
|
|
469
|
+
end: local.offset + local.name.length,
|
|
470
|
+
severity: 2,
|
|
471
|
+
message: `'${local.name}' is imported but never used.`,
|
|
472
|
+
code: 'unused-import',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
script,
|
|
480
|
+
declarations: s.declarations,
|
|
481
|
+
imports: s.imports,
|
|
482
|
+
props: [...s.declarations].filter(([, d]) => d.kind === 'prop').map(([name, d]) => ({ name, offset: d.offset })),
|
|
483
|
+
templateRefs: t.refs,
|
|
484
|
+
eachBlocks: t.eachBlocks,
|
|
485
|
+
importTags: t.importTags,
|
|
486
|
+
diagnostics,
|
|
487
|
+
};
|
|
488
|
+
}
|
package/src/docs.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hover documentation + completion entries for Spark's template directives.
|
|
3
|
+
* One source of truth: `DIRECTIVES` drives both features.
|
|
4
|
+
*
|
|
5
|
+
* `match` is what the cursor word/attribute must look like; entries with
|
|
6
|
+
* `insert` differing from `label` use LSP insertText (e.g. snippets-lite).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DIRECTIVES = [
|
|
10
|
+
{
|
|
11
|
+
label: 'import',
|
|
12
|
+
match: /^import$/,
|
|
13
|
+
detail: 'component placeholder',
|
|
14
|
+
doc: 'Import a component: `<div import="components/card"></div>`. The path resolves against the importing file (`.html` optional). Extra attributes become props (`export let` in the target). Full URLs work too: `<div import="https://…/card.html">`.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
label: 'each',
|
|
18
|
+
match: /^each$/,
|
|
19
|
+
detail: 'loop — <template each="item in items">',
|
|
20
|
+
doc: 'Repeat the template content per array item: `<template each="todo in todos">`. With index: `each="todo, i in todos"`. Add `key="todo.id"` for keyed reconciliation (rows move instead of being rewritten).',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
label: 'key',
|
|
24
|
+
match: /^key$/,
|
|
25
|
+
detail: 'keyed reconciliation for each',
|
|
26
|
+
doc: 'Stable identity per row: `<template each="row in rows" key="row.id">`. Evaluated per item; rows with the same key are reused in place when the array reorders.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: 'if',
|
|
30
|
+
match: /^if$/,
|
|
31
|
+
detail: 'conditional — <template if="expr">',
|
|
32
|
+
doc: 'Mount the content while the expression is truthy: `<template if="show">…</template>`. Real DOM is added/removed, not hidden. Chain `<template else-if>` / `<template else>` directly after.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'else-if',
|
|
36
|
+
match: /^else-if$/,
|
|
37
|
+
detail: 'conditional chain branch',
|
|
38
|
+
doc: 'A branch chained directly after `<template if>`: `<template else-if="score > 60">`. The first truthy branch in the chain renders.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: 'else',
|
|
42
|
+
match: /^else$/,
|
|
43
|
+
detail: 'conditional fallback branch',
|
|
44
|
+
doc: 'The fallback chained directly after `<template if>` / `<template else-if>`. Renders when no earlier branch matched.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: 'await',
|
|
48
|
+
match: /^await$/,
|
|
49
|
+
detail: 'async block — <template await="promise">',
|
|
50
|
+
doc: 'Declarative loading states: `<template await="loadUser(id)">` shows its content while pending, `<template then>` when resolved (value available as `{await}`), `<template catch>` on rejection (`{await.message}`). Re-evaluates when dependencies change; wrap in `once(…)` to fire only on mount.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: 'then',
|
|
54
|
+
match: /^then$/,
|
|
55
|
+
detail: 'await resolved branch',
|
|
56
|
+
doc: 'Inside `<template await>`: renders when the promise resolves. The resolved value is `{await}` — e.g. `{await.name}`.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: 'catch',
|
|
60
|
+
match: /^catch$/,
|
|
61
|
+
detail: 'await rejected branch',
|
|
62
|
+
doc: 'Inside `<template await>`: renders when the promise rejects. The error is `{await}` — e.g. `{await.message}`.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
label: 'route',
|
|
66
|
+
match: /^route$/,
|
|
67
|
+
detail: 'spark-html-router — <template route="/path">',
|
|
68
|
+
doc: 'A router outlet: `<template route="/docs">` mounts its content when the URL matches. Params: `route="/post/:id"` → `useStore(\'route\').params.id`; query via `route.query`; `route="*"` is the 404 catch-all. Requires `router()` from `spark-html-router`.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
label: 'bind:value',
|
|
72
|
+
match: /^bind:value$/,
|
|
73
|
+
detail: 'two-way binding — inputs, selects, textareas',
|
|
74
|
+
doc: 'Two-way binding: `<input bind:value="draft">` keeps the element and the variable in sync both ways.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: 'bind:checked',
|
|
78
|
+
match: /^bind:checked$/,
|
|
79
|
+
detail: 'two-way binding — checkboxes',
|
|
80
|
+
doc: 'Two-way binding for checkboxes: `<input type="checkbox" bind:checked="done">`.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: 'bind:group',
|
|
84
|
+
match: /^bind:group$/,
|
|
85
|
+
detail: 'two-way binding — radio groups / checkbox arrays',
|
|
86
|
+
doc: 'Bind a set of inputs to one variable: radios write the selected value, checkbox groups collect an array. `<input type="radio" bind:group="size" value="L">`.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: 'bind:form',
|
|
90
|
+
match: /^bind:form$/,
|
|
91
|
+
detail: 'two-way binding — whole form as an object',
|
|
92
|
+
doc: 'Bind an entire form to one object keyed by field names: `<form bind:form="signup">`.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: 'transition',
|
|
96
|
+
match: /^transition(:(fade|slide|scale))?$/,
|
|
97
|
+
detail: 'spark-html-motion — enter/leave animation',
|
|
98
|
+
doc: 'Animate elements as `if`/`each` blocks add or remove them: `transition="fade"` (or `slide`/`scale`; directive form `transition:fade` works too). Tune with `transition-duration="300"` (ms) and `transition-easing="ease-out"`. Requires `motion()` from `spark-html-motion`. Honors prefers-reduced-motion.',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: 'transition-duration',
|
|
102
|
+
match: /^transition-duration$/,
|
|
103
|
+
detail: 'spark-html-motion — duration in ms',
|
|
104
|
+
doc: 'Duration of the enter/leave transition in milliseconds: `transition-duration="300"`.',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
label: 'transition-easing',
|
|
108
|
+
match: /^transition-easing$/,
|
|
109
|
+
detail: 'spark-html-motion — CSS easing',
|
|
110
|
+
doc: 'Easing for the enter/leave transition: `transition-easing="ease-out"` (any CSS easing).',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
label: 'spark-ignore',
|
|
114
|
+
match: /^spark-ignore$/,
|
|
115
|
+
detail: 'escape hatch — subtree never patched',
|
|
116
|
+
doc: 'Spark never touches this element or its children — no interpolation, no patching. For third-party widgets and code samples containing literal `{braces}`.',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
label: ':hidden',
|
|
120
|
+
match: /^:[\w-]+$/,
|
|
121
|
+
detail: 'dynamic attribute — :attr="expr"',
|
|
122
|
+
doc: 'Any attribute prefixed with `:` is re-evaluated on every state change: `<button :disabled="count >= 10">`, `<div :class="active ? \'on\' : \'off\'">`, `<p :hidden="!open">`. Boolean results toggle the attribute; other values are set as strings.',
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Script-side builtins (hover inside <script>).
|
|
127
|
+
export const SCRIPT_BUILTINS = {
|
|
128
|
+
useStore: 'Subscribe this component to a named store and return its reactive proxy: `const cart = useStore(\'cart\')`. The store must be created with `store(name, initial)` before `mount()`.',
|
|
129
|
+
onMount: 'Run a callback after the component is mounted and painted: `onMount(() => { …; return () => cleanup(); })`. The returned function runs when the component is destroyed.',
|
|
130
|
+
props: 'The raw props object passed from the import placeholder\'s attributes. Prefer `export let name = default` for individual declared props.',
|
|
131
|
+
'$:': 'Reactive statement: `$: doubled = count * 2` re-runs whenever any variable it reads changes, before the DOM patch. The assigned variable is implicitly declared.',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function directiveDoc(word) {
|
|
135
|
+
for (const d of DIRECTIVES) if (d.match.test(word)) return d;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Completion list for attribute position (label, detail, doc).
|
|
140
|
+
export function directiveCompletions() {
|
|
141
|
+
const items = DIRECTIVES.filter((d) => d.label !== ':hidden').map((d) => ({
|
|
142
|
+
label: d.label,
|
|
143
|
+
detail: d.detail,
|
|
144
|
+
documentation: d.doc,
|
|
145
|
+
}));
|
|
146
|
+
for (const attr of [':hidden', ':disabled', ':class', ':style']) {
|
|
147
|
+
items.push({
|
|
148
|
+
label: attr,
|
|
149
|
+
detail: 'dynamic attribute — re-evaluated on every state change',
|
|
150
|
+
documentation: DIRECTIVES.find((d) => d.label === ':hidden').doc,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
for (const t of ['transition:fade', 'transition:slide', 'transition:scale']) {
|
|
154
|
+
items.push({
|
|
155
|
+
label: t,
|
|
156
|
+
detail: 'spark-html-motion — enter/leave animation',
|
|
157
|
+
documentation: DIRECTIVES.find((d) => d.label === 'transition').doc,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return items;
|
|
161
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-html-language-server — LSP for single-file Spark components.
|
|
3
|
+
*
|
|
4
|
+
* Most users never import this module: editors launch the
|
|
5
|
+
* `spark-html-language-server` binary (LSP over stdio). The programmatic API
|
|
6
|
+
* exists for tests and tooling that want the analyzer directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Declaration {
|
|
10
|
+
kind: 'prop' | 'let' | 'function' | 'reactive' | 'import';
|
|
11
|
+
offset: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Diagnostic {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
/** LSP severity: 1 error, 2 warning, 3 info, 4 hint */
|
|
18
|
+
severity: 1 | 2 | 3 | 4;
|
|
19
|
+
message: string;
|
|
20
|
+
code: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Analysis {
|
|
24
|
+
script: { start: number; end: number } | null;
|
|
25
|
+
declarations: Map<string, Declaration>;
|
|
26
|
+
props: { name: string; offset: number }[];
|
|
27
|
+
imports: { spec: string; locals: { name: string; offset: number }[] }[];
|
|
28
|
+
templateRefs: { name: string; offset: number }[];
|
|
29
|
+
importTags: { path: string; valueStart: number; valueEnd: number; tagStart: number; tagEnd: number }[];
|
|
30
|
+
diagnostics: Diagnostic[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Analyze a component source string (offsets are into that string). */
|
|
34
|
+
export function analyze(text: string): Analysis;
|
|
35
|
+
|
|
36
|
+
/** The LSP server core — transport-agnostic; feed it decoded JSON-RPC messages. */
|
|
37
|
+
export class SparkLanguageServer {
|
|
38
|
+
constructor(options: { send: (message: object) => void });
|
|
39
|
+
handle(message: { id?: number | string; method: string; params?: any }): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Start the server on stdio with Content-Length framing (what the bin runs). */
|
|
43
|
+
export function connectStdio(): SparkLanguageServer;
|
|
44
|
+
|
|
45
|
+
export function offsetToPosition(text: string, offset: number): { line: number; character: number };
|
|
46
|
+
export function positionToOffset(text: string, pos: { line: number; character: number }): number;
|
package/src/index.js
ADDED
package/src/server.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-html-language-server — the LSP server.
|
|
3
|
+
*
|
|
4
|
+
* Speaks Language Server Protocol over JSON-RPC. Transport-agnostic: the
|
|
5
|
+
* class handles decoded messages and emits replies through `send`; the CLI
|
|
6
|
+
* (bin/cli.js) wires it to stdio with Content-Length framing. Tests drive
|
|
7
|
+
* `handle()` directly — no child process, no flakiness.
|
|
8
|
+
*
|
|
9
|
+
* Features: publishDiagnostics (on open/change), completion (component props
|
|
10
|
+
* from the target's `export let`, template directives, script symbols),
|
|
11
|
+
* hover (directive + builtin docs, declaration info), and go-to-definition
|
|
12
|
+
* (component imports → file, identifiers → their declaration).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
|
+
import { analyze } from './analyze.js';
|
|
19
|
+
import { directiveDoc, directiveCompletions, SCRIPT_BUILTINS } from './docs.js';
|
|
20
|
+
|
|
21
|
+
// ── position mapping ───────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function offsetToPosition(text, offset) {
|
|
24
|
+
let line = 0;
|
|
25
|
+
let last = 0;
|
|
26
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
27
|
+
if (text[i] === '\n') { line++; last = i + 1; }
|
|
28
|
+
}
|
|
29
|
+
return { line, character: Math.min(offset, text.length) - last };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function positionToOffset(text, pos) {
|
|
33
|
+
let line = 0;
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (line < pos.line && i < text.length) {
|
|
36
|
+
if (text[i] === '\n') line++;
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return Math.min(i + pos.character, text.length);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const range = (text, start, end) => ({
|
|
43
|
+
start: offsetToPosition(text, start),
|
|
44
|
+
end: offsetToPosition(text, end),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Word under the cursor, including the directive charset (`bind:value`,
|
|
48
|
+
// `:hidden`, `else-if`, `$:`).
|
|
49
|
+
function wordAt(text, offset) {
|
|
50
|
+
const chars = /[\w$:.-]/;
|
|
51
|
+
let s = offset;
|
|
52
|
+
let e = offset;
|
|
53
|
+
while (s > 0 && chars.test(text[s - 1])) s--;
|
|
54
|
+
while (e < text.length && chars.test(text[e])) e++;
|
|
55
|
+
return { word: text.slice(s, e), start: s, end: e };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── server ─────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export class SparkLanguageServer {
|
|
61
|
+
constructor({ send }) {
|
|
62
|
+
this.send = send;
|
|
63
|
+
this.docs = new Map(); // uri -> { text, analysis }
|
|
64
|
+
this.rootPath = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handle(msg) {
|
|
68
|
+
const { id, method, params } = msg;
|
|
69
|
+
const respond = (result) => this.send({ jsonrpc: '2.0', id, result });
|
|
70
|
+
try {
|
|
71
|
+
switch (method) {
|
|
72
|
+
case 'initialize':
|
|
73
|
+
this.rootPath = params?.rootUri ? fileURLToPath(params.rootUri)
|
|
74
|
+
: params?.rootPath || null;
|
|
75
|
+
return respond({
|
|
76
|
+
capabilities: {
|
|
77
|
+
textDocumentSync: 1, // full
|
|
78
|
+
completionProvider: { triggerCharacters: ['{', ':', '"', ' ', '.'] },
|
|
79
|
+
hoverProvider: true,
|
|
80
|
+
definitionProvider: true,
|
|
81
|
+
},
|
|
82
|
+
serverInfo: { name: 'spark-html-language-server' },
|
|
83
|
+
});
|
|
84
|
+
case 'initialized':
|
|
85
|
+
case 'exit':
|
|
86
|
+
return;
|
|
87
|
+
case 'shutdown':
|
|
88
|
+
return respond(null);
|
|
89
|
+
case 'textDocument/didOpen':
|
|
90
|
+
return this.open(params.textDocument.uri, params.textDocument.text);
|
|
91
|
+
case 'textDocument/didChange':
|
|
92
|
+
return this.open(params.textDocument.uri, params.contentChanges[0].text);
|
|
93
|
+
case 'textDocument/didClose':
|
|
94
|
+
this.docs.delete(params.textDocument.uri);
|
|
95
|
+
return this.send({
|
|
96
|
+
jsonrpc: '2.0',
|
|
97
|
+
method: 'textDocument/publishDiagnostics',
|
|
98
|
+
params: { uri: params.textDocument.uri, diagnostics: [] },
|
|
99
|
+
});
|
|
100
|
+
case 'textDocument/completion':
|
|
101
|
+
return respond(this.completion(params));
|
|
102
|
+
case 'textDocument/hover':
|
|
103
|
+
return respond(this.hover(params));
|
|
104
|
+
case 'textDocument/definition':
|
|
105
|
+
return respond(this.definition(params));
|
|
106
|
+
default:
|
|
107
|
+
// Respond to unknown requests (they have an id) so clients don't hang.
|
|
108
|
+
if (id !== undefined) respond(null);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (id !== undefined) {
|
|
112
|
+
this.send({ jsonrpc: '2.0', id, error: { code: -32603, message: e.message } });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
open(uri, text) {
|
|
118
|
+
const analysis = analyze(text);
|
|
119
|
+
this.docs.set(uri, { text, analysis });
|
|
120
|
+
const diagnostics = analysis.diagnostics.map((d) => ({
|
|
121
|
+
range: range(text, d.start, d.end),
|
|
122
|
+
severity: d.severity,
|
|
123
|
+
message: d.message,
|
|
124
|
+
code: d.code,
|
|
125
|
+
source: 'spark',
|
|
126
|
+
...(d.code === 'unused-import' ? { tags: [1] } : {}), // Unnecessary
|
|
127
|
+
}));
|
|
128
|
+
// Component placeholders pointing at files that don't exist.
|
|
129
|
+
for (const tag of analysis.importTags) {
|
|
130
|
+
const file = this.resolveImport(uri, tag.path);
|
|
131
|
+
if (file === undefined) continue; // remote / unresolvable — not checked
|
|
132
|
+
if (!file) {
|
|
133
|
+
diagnostics.push({
|
|
134
|
+
range: range(text, tag.valueStart, tag.valueEnd),
|
|
135
|
+
severity: 2,
|
|
136
|
+
message: `Component file not found: "${tag.path}" (looked for it relative to this file${this.rootPath ? ' and the workspace root' : ''}).`,
|
|
137
|
+
code: 'component-not-found',
|
|
138
|
+
source: 'spark',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.send({
|
|
143
|
+
jsonrpc: '2.0',
|
|
144
|
+
method: 'textDocument/publishDiagnostics',
|
|
145
|
+
params: { uri, diagnostics },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve a placeholder path to an existing file. Returns the path, null
|
|
150
|
+
// (checked, missing), or undefined (remote URL — out of scope).
|
|
151
|
+
resolveImport(docUri, path) {
|
|
152
|
+
if (/^[a-z]+:\/\//i.test(path)) return undefined;
|
|
153
|
+
let docDir;
|
|
154
|
+
try { docDir = dirname(fileURLToPath(docUri)); } catch { return undefined; }
|
|
155
|
+
const bases = path.startsWith('/')
|
|
156
|
+
? [this.rootPath, resolve(this.rootPath || docDir, 'public')].filter(Boolean)
|
|
157
|
+
: [docDir];
|
|
158
|
+
for (const base of bases) {
|
|
159
|
+
const target = path.startsWith('/') ? resolve(base, path.slice(1)) : resolve(base, path);
|
|
160
|
+
for (const candidate of [target, `${target}.html`]) {
|
|
161
|
+
if (existsSync(candidate)) return candidate;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Components often live under public/ (served from the web root).
|
|
165
|
+
if (!path.startsWith('/')) {
|
|
166
|
+
for (const extra of ['public', '.']) {
|
|
167
|
+
if (!this.rootPath) break;
|
|
168
|
+
const target = resolve(this.rootPath, extra, path);
|
|
169
|
+
for (const candidate of [target, `${target}.html`]) {
|
|
170
|
+
if (existsSync(candidate)) return candidate;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── completion ───────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
completion({ textDocument, position }) {
|
|
180
|
+
const doc = this.docs.get(textDocument.uri);
|
|
181
|
+
if (!doc) return null;
|
|
182
|
+
const { text, analysis } = doc;
|
|
183
|
+
const offset = positionToOffset(text, position);
|
|
184
|
+
|
|
185
|
+
const symbolItems = () => {
|
|
186
|
+
const items = [];
|
|
187
|
+
for (const [name, d] of analysis.declarations) {
|
|
188
|
+
items.push({
|
|
189
|
+
label: name,
|
|
190
|
+
kind: d.kind === 'function' ? 3 : d.kind === 'prop' ? 5 : 6,
|
|
191
|
+
detail: { prop: 'prop (export let)', let: 'state (let)', function: 'function', reactive: 'reactive ($:)', import: 'import' }[d.kind],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const [name, doc_] of Object.entries(SCRIPT_BUILTINS)) {
|
|
195
|
+
if (name === '$:') continue;
|
|
196
|
+
items.push({ label: name, kind: 3, detail: 'spark builtin', documentation: doc_ });
|
|
197
|
+
}
|
|
198
|
+
return items;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Inside the component <script>? Complete script symbols.
|
|
202
|
+
const s = analysis.script;
|
|
203
|
+
if (s && offset >= s.start && offset <= s.end) return { isIncomplete: false, items: symbolItems() };
|
|
204
|
+
|
|
205
|
+
// Inside a tag (a `<` after the last `>`)?
|
|
206
|
+
const lastOpen = text.lastIndexOf('<', offset - 1);
|
|
207
|
+
const lastClose = text.lastIndexOf('>', offset - 1);
|
|
208
|
+
if (lastOpen > lastClose) {
|
|
209
|
+
const tagText = text.slice(lastOpen, offset);
|
|
210
|
+
const quotes = (tagText.match(/"/g) || []).length;
|
|
211
|
+
if (quotes % 2 === 0) {
|
|
212
|
+
// Attribute-name position. On a placeholder, offer the target's props.
|
|
213
|
+
const impM = tagText.match(/\bimport\s*=\s*"([^"]*)"/);
|
|
214
|
+
const items = [];
|
|
215
|
+
if (impM) {
|
|
216
|
+
const file = this.resolveImport(textDocument.uri, impM[1]);
|
|
217
|
+
if (file) {
|
|
218
|
+
try {
|
|
219
|
+
const target = analyze(readFileSync(file, 'utf8'));
|
|
220
|
+
for (const p of target.props) {
|
|
221
|
+
items.push({ label: p.name, kind: 5, detail: `prop of ${impM[1]}`, sortText: `0${p.name}` });
|
|
222
|
+
}
|
|
223
|
+
} catch { /* unreadable target — just skip props */ }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
items.push(...directiveCompletions().map((c) => ({ ...c, kind: 14 })));
|
|
227
|
+
return { isIncomplete: false, items };
|
|
228
|
+
}
|
|
229
|
+
return null; // inside an attribute value — no completions
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// After an unclosed `{` in text? Complete script symbols.
|
|
233
|
+
const braceOpen = text.lastIndexOf('{', offset - 1);
|
|
234
|
+
if (braceOpen > -1 && braceOpen > text.lastIndexOf('}', offset - 1) && offset - braceOpen < 200) {
|
|
235
|
+
return { isIncomplete: false, items: symbolItems() };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── hover ────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
hover({ textDocument, position }) {
|
|
243
|
+
const doc = this.docs.get(textDocument.uri);
|
|
244
|
+
if (!doc) return null;
|
|
245
|
+
const { text, analysis } = doc;
|
|
246
|
+
const offset = positionToOffset(text, position);
|
|
247
|
+
const { word, start, end } = wordAt(text, offset);
|
|
248
|
+
if (!word) return null;
|
|
249
|
+
const md = (value) => ({
|
|
250
|
+
contents: { kind: 'markdown', value },
|
|
251
|
+
range: range(text, start, end),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const s = analysis.script;
|
|
255
|
+
const inScript = s && offset >= s.start && offset <= s.end;
|
|
256
|
+
|
|
257
|
+
if (inScript) {
|
|
258
|
+
if (word === '$' || word.startsWith('$:')) return md(`**\`$:\`** — ${SCRIPT_BUILTINS['$:']}`);
|
|
259
|
+
if (SCRIPT_BUILTINS[word]) return md(`**\`${word}\`** — ${SCRIPT_BUILTINS[word]}`);
|
|
260
|
+
} else {
|
|
261
|
+
const d = directiveDoc(word.replace(/=.*$/, ''));
|
|
262
|
+
if (d) return md(`**\`${d.label}\`** · ${d.detail}\n\n${d.doc}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const bare = word.match(/[A-Za-z_$][\w$]*/)?.[0];
|
|
266
|
+
const decl = bare && analysis.declarations.get(bare);
|
|
267
|
+
if (decl) {
|
|
268
|
+
const label = {
|
|
269
|
+
prop: `prop — \`export let ${bare}\` (overridable from the import placeholder)`,
|
|
270
|
+
let: `component state — \`let ${bare}\` (reactive)`,
|
|
271
|
+
function: `function \`${bare}()\``,
|
|
272
|
+
reactive: `derived — \`$: ${bare} = …\` (recomputed on every change)`,
|
|
273
|
+
import: `imported binding \`${bare}\``,
|
|
274
|
+
}[decl.kind];
|
|
275
|
+
return md(label);
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── definition ───────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
definition({ textDocument, position }) {
|
|
283
|
+
const doc = this.docs.get(textDocument.uri);
|
|
284
|
+
if (!doc) return null;
|
|
285
|
+
const { text, analysis } = doc;
|
|
286
|
+
const offset = positionToOffset(text, position);
|
|
287
|
+
|
|
288
|
+
// On an import="…" value → the component file.
|
|
289
|
+
for (const tag of analysis.importTags) {
|
|
290
|
+
if (offset >= tag.tagStart && offset <= (tag.tagEnd === -1 ? text.length : tag.tagEnd)) {
|
|
291
|
+
const file = this.resolveImport(textDocument.uri, tag.path);
|
|
292
|
+
if (file) {
|
|
293
|
+
return {
|
|
294
|
+
uri: pathToFileURL(file).href,
|
|
295
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// On an identifier → its declaration in this component.
|
|
302
|
+
const { word } = wordAt(text, offset);
|
|
303
|
+
const bare = word.match(/[A-Za-z_$][\w$]*/)?.[0];
|
|
304
|
+
const decl = bare && analysis.declarations.get(bare);
|
|
305
|
+
if (decl && decl.offset !== undefined) {
|
|
306
|
+
return {
|
|
307
|
+
uri: textDocument.uri,
|
|
308
|
+
range: range(text, decl.offset, decl.offset + bare.length),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── stdio transport (Content-Length framing) ───────────────────────────────
|
|
316
|
+
|
|
317
|
+
export function connectStdio() {
|
|
318
|
+
const server = new SparkLanguageServer({
|
|
319
|
+
send: (msg) => {
|
|
320
|
+
const body = JSON.stringify(msg);
|
|
321
|
+
process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
let buffer = Buffer.alloc(0);
|
|
325
|
+
process.stdin.on('data', (chunk) => {
|
|
326
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
327
|
+
for (;;) {
|
|
328
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
329
|
+
if (headerEnd === -1) return;
|
|
330
|
+
const header = buffer.slice(0, headerEnd).toString();
|
|
331
|
+
const length = Number(header.match(/Content-Length:\s*(\d+)/i)?.[1]);
|
|
332
|
+
if (!length || buffer.length < headerEnd + 4 + length) return;
|
|
333
|
+
const body = buffer.slice(headerEnd + 4, headerEnd + 4 + length).toString();
|
|
334
|
+
buffer = buffer.slice(headerEnd + 4 + length);
|
|
335
|
+
let msg;
|
|
336
|
+
try { msg = JSON.parse(body); } catch { continue; }
|
|
337
|
+
if (msg.method === 'exit') process.exit(0);
|
|
338
|
+
server.handle(msg);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
process.stdin.on('end', () => process.exit(0));
|
|
342
|
+
return server;
|
|
343
|
+
}
|