@tiaforge-ui/sinter 0.0.0-stage → 0.2.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -2
  3. package/dist/cache.d.ts +5 -0
  4. package/dist/cache.js +95 -0
  5. package/dist/check/index.d.ts +85 -0
  6. package/dist/check/index.js +156 -0
  7. package/dist/check/openapi-types.d.ts +6 -0
  8. package/dist/check/openapi-types.js +84 -0
  9. package/dist/check/shadow.d.ts +49 -0
  10. package/dist/check/shadow.js +279 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +67 -0
  13. package/dist/compiler/check-unit.d.ts +109 -0
  14. package/dist/compiler/check-unit.js +3 -0
  15. package/dist/compiler/compile.d.ts +66 -0
  16. package/dist/compiler/compile.js +1316 -0
  17. package/dist/compiler/diagnostic-docs.d.ts +10 -0
  18. package/dist/compiler/diagnostic-docs.js +142 -0
  19. package/dist/compiler/diagnostics.d.ts +57 -0
  20. package/dist/compiler/diagnostics.js +77 -0
  21. package/dist/compiler/expr.d.ts +97 -0
  22. package/dist/compiler/expr.js +853 -0
  23. package/dist/compiler/index.d.ts +22 -0
  24. package/dist/compiler/index.js +160 -0
  25. package/dist/compiler/openapi.d.ts +40 -0
  26. package/dist/compiler/openapi.js +177 -0
  27. package/dist/compiler/vocabulary.d.ts +12 -0
  28. package/dist/compiler/vocabulary.js +39 -0
  29. package/dist/dev.d.ts +10 -0
  30. package/dist/dev.js +92 -0
  31. package/dist/each.d.ts +3 -0
  32. package/dist/each.js +121 -0
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.js +4 -0
  35. package/dist/ir.d.ts +152 -0
  36. package/dist/ir.js +121 -0
  37. package/dist/lsp/quickfix.d.ts +15 -0
  38. package/dist/lsp/quickfix.js +58 -0
  39. package/dist/lsp/rpc.d.ts +31 -0
  40. package/dist/lsp/rpc.js +120 -0
  41. package/dist/lsp/server.d.ts +36 -0
  42. package/dist/lsp/server.js +281 -0
  43. package/dist/lsp/spec-location.d.ts +9 -0
  44. package/dist/lsp/spec-location.js +42 -0
  45. package/dist/lsp/typescript.d.ts +77 -0
  46. package/dist/lsp/typescript.js +242 -0
  47. package/dist/lsp/workspace.d.ts +56 -0
  48. package/dist/lsp/workspace.js +228 -0
  49. package/dist/optimistic.d.ts +3 -0
  50. package/dist/optimistic.js +15 -0
  51. package/dist/paginate.d.ts +3 -0
  52. package/dist/paginate.js +198 -0
  53. package/dist/persist.d.ts +3 -0
  54. package/dist/persist.js +55 -0
  55. package/dist/project.d.ts +42 -0
  56. package/dist/project.js +103 -0
  57. package/dist/runtime/app.d.ts +152 -0
  58. package/dist/runtime/app.js +1256 -0
  59. package/dist/runtime/config.d.ts +53 -0
  60. package/dist/runtime/config.js +118 -0
  61. package/dist/runtime/env.d.ts +18 -0
  62. package/dist/runtime/env.js +23 -0
  63. package/dist/runtime/helpers.d.ts +9 -0
  64. package/dist/runtime/helpers.js +55 -0
  65. package/dist/runtime/request.d.ts +23 -0
  66. package/dist/runtime/request.js +93 -0
  67. package/dist/runtime/security.d.ts +12 -0
  68. package/dist/runtime/security.js +34 -0
  69. package/dist/runtime/store.d.ts +98 -0
  70. package/dist/runtime/store.js +255 -0
  71. package/dist/sse.d.ts +7 -0
  72. package/dist/sse.js +209 -0
  73. package/dist/testing/index.d.ts +73 -0
  74. package/dist/testing/index.js +359 -0
  75. package/dist/vite.d.ts +11 -0
  76. package/dist/vite.js +96 -0
  77. package/dist/ws.d.ts +18 -0
  78. package/dist/ws.js +57 -0
  79. package/docs/diagnostics.md +283 -0
  80. package/html-custom-data.json +250 -0
  81. package/llms.txt +632 -0
  82. package/package.json +93 -4
@@ -0,0 +1,281 @@
1
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
+ import { CODES, docsUrl } from '../compiler/index.js';
3
+ import { check, findTsc } from '../check/index.js';
4
+ import { Connection } from './rpc.js';
5
+ import { existsSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { didYouMeanEdit } from './quickfix.js';
8
+ import { TypeScriptBridge, typescriptMajor } from './typescript.js';
9
+ import { Workspace } from './workspace.js';
10
+ /** How long typing must pause before TypeScript checks the pages. */
11
+ export const TYPECHECK_DELAY = 300;
12
+ /** The range a diagnostic underlines: the name or word at its position, else one character. */
13
+ export function rangeOf(text, line, col) {
14
+ const lines = text.split('\n');
15
+ const l = Math.max(0, Math.min(line - 1, lines.length - 1));
16
+ const s = lines[l] ?? '';
17
+ const c = Math.max(0, Math.min(col - 1, s.length));
18
+ const m = /^[\w$-]+/.exec(s.slice(c));
19
+ const end = m ? c + m[0].length : Math.min(c + 1, s.length);
20
+ return { start: { line: l, character: c }, end: { line: l, character: end > c ? end : c } };
21
+ }
22
+ export function toLsp(d, text) {
23
+ const name = CODES[d.code];
24
+ return {
25
+ range: d.span
26
+ ? { start: { line: d.span.line - 1, character: d.span.col - 1 }, end: { line: d.span.endLine - 1, character: d.span.endCol - 1 } }
27
+ : rangeOf(text, d.line, d.col),
28
+ severity: d.severity === 'error' ? 1 : 2,
29
+ code: name ? `${d.code} ${name}` : d.code,
30
+ codeDescription: { href: docsUrl(d.code) },
31
+ source: 'sinter',
32
+ message: d.message + (d.hint ? `\nhint: ${d.hint}` : ''),
33
+ };
34
+ }
35
+ const uriToPath = (uri) => fileURLToPath(uri);
36
+ const pathToUri = (path) => pathToFileURL(path).href;
37
+ export function startServer(input, output, o = {}) {
38
+ const conn = new Connection(input, output);
39
+ const last = new Map();
40
+ /** per project root: the TypeScript bridge, 'cli' for TypeScript before 7 (checked on save), or 'none' */
41
+ const typescript = new Map();
42
+ const timers = new Map();
43
+ const delay = o.typecheckDelay ?? TYPECHECK_DELAY;
44
+ let disposed = false;
45
+ const modeOf = (root) => {
46
+ let m = typescript.get(root);
47
+ if (m)
48
+ return m;
49
+ try {
50
+ const tsc = o.tsc ?? findTsc(root);
51
+ m = typescriptMajor(tsc) >= 7 ? new TypeScriptBridge(root, tsc) : 'cli';
52
+ }
53
+ catch {
54
+ m = 'none';
55
+ }
56
+ typescript.set(root, m);
57
+ return m;
58
+ };
59
+ const typecheck = async (state) => {
60
+ const mode = modeOf(state.root);
61
+ if (mode === 'none')
62
+ return;
63
+ if (mode === 'cli') {
64
+ const p = state.project;
65
+ const r = check({ root: state.root, units: [...state.units.values()], helpers: p.helpers, types: p.types, openapi: p.openapi, tsc: o.tsc ?? findTsc(state.root) });
66
+ await workspace.setTypes(state, '*', r.diagnostics);
67
+ return;
68
+ }
69
+ await mode.update(state.project, state.units);
70
+ for (const page of state.units.keys())
71
+ await workspace.setTypes(state, page, await mode.diagnostics(page));
72
+ };
73
+ const schedule = (state, wait) => {
74
+ clearTimeout(timers.get(state.root));
75
+ timers.set(state.root, setTimeout(() => {
76
+ timers.delete(state.root);
77
+ typecheck(state).catch((e) => disposed || process.stderr.write(`sinter lsp: type check failed: ${e instanceof Error ? e.stack : String(e)}\n`));
78
+ }, wait));
79
+ };
80
+ const workspace = new Workspace({
81
+ compiled(state) {
82
+ // before TypeScript 7 the check runs `tsc` once per save instead
83
+ if (modeOf(state.root) !== 'cli')
84
+ schedule(state, delay);
85
+ },
86
+ publish(file, diagnostics) {
87
+ let text = '';
88
+ try {
89
+ text = workspace.read(file);
90
+ }
91
+ catch { /* deleted: ranges fall back to line starts */ }
92
+ const params = { uri: pathToUri(file), diagnostics: diagnostics.map((d) => toLsp(d, text)) };
93
+ const json = JSON.stringify(params.diagnostics);
94
+ if (last.get(file) === json)
95
+ return;
96
+ last.set(file, json);
97
+ conn.notify('textDocument/publishDiagnostics', params);
98
+ },
99
+ });
100
+ let watchDynamic = false;
101
+ let shutdown = false;
102
+ conn.onRequest('initialize', (params) => {
103
+ watchDynamic = !!params?.capabilities?.workspace?.didChangeWatchedFiles?.dynamicRegistration;
104
+ const folders = (params?.workspaceFolders ?? []).map((f) => uriToPath(f.uri));
105
+ if (!folders.length && params?.rootUri)
106
+ folders.push(uriToPath(params.rootUri));
107
+ // load the projects of the open folders early, so the first open file is quick
108
+ for (const f of folders)
109
+ void workspace.projectFor(f + '/index.html').catch(() => { });
110
+ return {
111
+ capabilities: {
112
+ // full text on every change
113
+ textDocumentSync: { openClose: true, change: 1, save: { includeText: false } },
114
+ hoverProvider: true,
115
+ completionProvider: { triggerCharacters: ['.'], resolveProvider: true },
116
+ definitionProvider: true,
117
+ codeActionProvider: { codeActionKinds: ['quickfix'] },
118
+ },
119
+ serverInfo: { name: 'sinter' },
120
+ };
121
+ });
122
+ conn.onNotification('initialized', () => {
123
+ if (!watchDynamic)
124
+ return;
125
+ void conn.request('client/registerCapability', {
126
+ registrations: [{
127
+ id: 'sinter-watch',
128
+ method: 'workspace/didChangeWatchedFiles',
129
+ registerOptions: {
130
+ watchers: [
131
+ { globPattern: '**/*.html' },
132
+ { globPattern: '**/vite.config.{js,ts,mjs,mts,cjs,cts}' },
133
+ { globPattern: '**/src/sinter.{helpers,types,config}.{js,ts}' },
134
+ { globPattern: '**/src/sinter.types.d.ts' },
135
+ { globPattern: '**/*.{yml,yaml,json}' },
136
+ { globPattern: '**/src/**/*.{js,ts}' },
137
+ ],
138
+ },
139
+ }],
140
+ }).catch(() => { });
141
+ });
142
+ const local = (uri) => uri.startsWith('file:');
143
+ conn.onNotification('textDocument/didOpen', (p) => {
144
+ if (local(p.textDocument.uri))
145
+ return workspace.open(uriToPath(p.textDocument.uri), p.textDocument.text);
146
+ });
147
+ conn.onNotification('textDocument/didChange', (p) => {
148
+ const text = p.contentChanges[p.contentChanges.length - 1]?.text;
149
+ if (local(p.textDocument.uri) && typeof text === 'string')
150
+ return workspace.change(uriToPath(p.textDocument.uri), text);
151
+ });
152
+ conn.onNotification('textDocument/didClose', (p) => {
153
+ if (local(p.textDocument.uri))
154
+ return workspace.close(uriToPath(p.textDocument.uri));
155
+ });
156
+ conn.onNotification('textDocument/didSave', async (p) => {
157
+ if (!local(p.textDocument.uri))
158
+ return;
159
+ const file = uriToPath(p.textDocument.uri);
160
+ await workspace.fileChanged(file);
161
+ const state = await workspace.projectFor(file);
162
+ if (modeOf(state.root) === 'cli')
163
+ schedule(state, 0);
164
+ });
165
+ conn.onNotification('workspace/didChangeWatchedFiles', async (p) => {
166
+ const files = (p.changes ?? []).filter((c) => local(c.uri)).map((c) => uriToPath(c.uri));
167
+ for (const m of typescript.values())
168
+ if (m instanceof TypeScriptBridge)
169
+ m.filesChanged(files.filter((f) => !f.endsWith('.html')));
170
+ for (const f of files)
171
+ await workspace.fileChanged(f);
172
+ });
173
+ conn.onRequest('textDocument/hover', async (p) => {
174
+ if (!local(p.textDocument.uri))
175
+ return null;
176
+ const file = uriToPath(p.textDocument.uri);
177
+ const state = await workspace.projectFor(file);
178
+ const mode = modeOf(state.root);
179
+ if (!(mode instanceof TypeScriptBridge))
180
+ return null;
181
+ // the shadows must reflect the latest edit
182
+ await mode.update(state.project, state.units);
183
+ return mode.hover(file, p.position.line + 1, p.position.character + 1);
184
+ });
185
+ /** the bridge for a file's project, with its shadows brought up to date */
186
+ const bridgeFor = async (file) => {
187
+ const state = await workspace.projectFor(file);
188
+ const mode = modeOf(state.root);
189
+ if (!(mode instanceof TypeScriptBridge))
190
+ return null;
191
+ await mode.update(state.project, state.units);
192
+ return { bridge: mode, state };
193
+ };
194
+ conn.onRequest('textDocument/completion', async (p) => {
195
+ if (!local(p.textDocument.uri))
196
+ return null;
197
+ const file = uriToPath(p.textDocument.uri);
198
+ const b = await bridgeFor(file);
199
+ return b ? b.bridge.completion(file, p.position.line + 1, p.position.character + 1, p.context) : null;
200
+ });
201
+ conn.onRequest('completionItem/resolve', async (item) => {
202
+ const m = item?.data?.sinter ? typescript.get(item.data.sinter) : null;
203
+ return m instanceof TypeScriptBridge ? m.resolve(item) : item;
204
+ });
205
+ const location = (t) => {
206
+ let text = '';
207
+ try {
208
+ text = workspace.read(t.file);
209
+ }
210
+ catch { /* the target's range falls back to its start */ }
211
+ const range = t.endLine !== undefined && t.endCol !== undefined
212
+ ? { start: { line: t.line - 1, character: t.col - 1 }, end: { line: t.endLine - 1, character: t.endCol - 1 } }
213
+ : rangeOf(text, t.line, t.col);
214
+ return { uri: pathToUri(t.file), range };
215
+ };
216
+ conn.onRequest('textDocument/definition', async (p) => {
217
+ if (!local(p.textDocument.uri))
218
+ return null;
219
+ const file = uriToPath(p.textDocument.uri);
220
+ const partial = includeAt(workspace.read(file), p.position.line, p.position.character);
221
+ if (partial !== null) {
222
+ const target = resolve(dirname(file), partial);
223
+ return existsSync(target) ? [{ uri: pathToUri(target), range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }] : null;
224
+ }
225
+ const b = await bridgeFor(file);
226
+ if (!b)
227
+ return null;
228
+ const targets = await b.bridge.definition(file, p.position.line + 1, p.position.character + 1, b.state.project);
229
+ return targets.map(location);
230
+ });
231
+ conn.onRequest('textDocument/codeAction', (p) => {
232
+ if (!local(p.textDocument.uri))
233
+ return [];
234
+ let text;
235
+ try {
236
+ text = workspace.read(uriToPath(p.textDocument.uri));
237
+ }
238
+ catch {
239
+ return [];
240
+ }
241
+ const actions = [];
242
+ for (const d of p.context?.diagnostics ?? []) {
243
+ if (d.source !== 'sinter')
244
+ continue;
245
+ const edit = didYouMeanEdit(text, d.range, d.message);
246
+ if (!edit)
247
+ continue;
248
+ actions.push({
249
+ title: `Change to '${edit.newText}'`, kind: 'quickfix', isPreferred: true, diagnostics: [d],
250
+ edit: { changes: { [p.textDocument.uri]: [edit] } },
251
+ });
252
+ }
253
+ return actions;
254
+ });
255
+ const dispose = () => {
256
+ disposed = true;
257
+ for (const t of timers.values())
258
+ clearTimeout(t);
259
+ timers.clear();
260
+ for (const m of typescript.values())
261
+ if (m instanceof TypeScriptBridge)
262
+ m.dispose();
263
+ typescript.clear();
264
+ };
265
+ conn.onRequest('shutdown', () => { shutdown = true; dispose(); return null; });
266
+ const closed = conn.onClose;
267
+ conn.onClose = () => { dispose(); closed?.(); };
268
+ conn.onNotification('exit', () => { conn.close(); o.exit?.(shutdown ? 0 : 1); });
269
+ conn.onUnhandled = () => { };
270
+ return conn;
271
+ }
272
+ /** The `src` of an `<sn-include>` when the 0-based position is inside its value, else null. */
273
+ export function includeAt(text, line, character) {
274
+ const t = text.split('\n')[line] ?? '';
275
+ for (const m of t.matchAll(/<sn-include\b[^>]*?\bsrc\s*=\s*(["'])([^"']*)\1/g)) {
276
+ const start = m.index + m[0].length - 1 - m[2].length;
277
+ if (character >= start && character <= start + m[2].length)
278
+ return m[2];
279
+ }
280
+ return null;
281
+ }
@@ -0,0 +1,9 @@
1
+ export interface SpecLocation {
2
+ line: number;
3
+ col: number;
4
+ }
5
+ /**
6
+ * The position of `components.schemas.<schema>` in `file`, or of its `properties.<prop>` when
7
+ * given and present. `schema` is the TypeScript name; the spec's own name is looked up.
8
+ */
9
+ export declare function specLocation(file: string, schema: string, prop?: string): SpecLocation | null;
@@ -0,0 +1,42 @@
1
+ // Where a named schema (or one of its properties) is written in an OpenAPI spec
2
+ // file, for go to definition on types generated from it. YAML and JSON alike: the
3
+ // `yaml` parser reads both and keeps source ranges.
4
+ import { readFileSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import { typeName } from '../check/openapi-types.js';
7
+ /** The key named `name` in a YAML map node. */
8
+ function pair(map, name) {
9
+ return map?.items?.find((p) => p.key?.value === name);
10
+ }
11
+ function lineCol(text, offset) {
12
+ let line = 1;
13
+ let start = 0;
14
+ for (let i = 0; i < offset && i < text.length; i++)
15
+ if (text[i] === '\n') {
16
+ line++;
17
+ start = i + 1;
18
+ }
19
+ return { line, col: offset - start + 1 };
20
+ }
21
+ /**
22
+ * The position of `components.schemas.<schema>` in `file`, or of its `properties.<prop>` when
23
+ * given and present. `schema` is the TypeScript name; the spec's own name is looked up.
24
+ */
25
+ export function specLocation(file, schema, prop) {
26
+ let text;
27
+ try {
28
+ text = readFileSync(file, 'utf8');
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ const yaml = createRequire(import.meta.url)('yaml');
34
+ const doc = yaml.parseDocument(text);
35
+ const schemas = pair(pair(doc.contents, 'components')?.value, 'schemas')?.value;
36
+ const entry = schemas?.items?.find((p) => typeof p.key?.value === 'string' && typeName(p.key.value) === schema);
37
+ if (!entry?.key?.range)
38
+ return null;
39
+ const property = prop ? pair(pair(entry.value, 'properties')?.value, prop) : undefined;
40
+ const at = property?.key?.range ?? entry.key.range;
41
+ return lineCol(text, at[0]);
42
+ }
@@ -0,0 +1,77 @@
1
+ import { type Region } from '../check/shadow.js';
2
+ import type { CheckUnit } from '../compiler/check-unit.js';
3
+ import type { Diagnostic } from '../compiler/index.js';
4
+ import type { Project } from '../project.js';
5
+ /** The major version of the project's TypeScript, from the package next to its `tsc`. */
6
+ export declare function typescriptMajor(tsc: string): number;
7
+ interface OpenShadow {
8
+ unit: CheckUnit;
9
+ path: string;
10
+ uri: string;
11
+ text: string;
12
+ lines: string[];
13
+ regions: Region[];
14
+ brokenFrom: number | null;
15
+ version: number;
16
+ }
17
+ /** A place in a file, 1-based; `end` when known. */
18
+ export interface Target {
19
+ file: string;
20
+ line: number;
21
+ col: number;
22
+ endLine?: number;
23
+ endCol?: number;
24
+ }
25
+ interface LspRange {
26
+ start: {
27
+ line: number;
28
+ character: number;
29
+ };
30
+ end: {
31
+ line: number;
32
+ character: number;
33
+ };
34
+ }
35
+ export declare class TypeScriptBridge {
36
+ readonly root: string;
37
+ private conn;
38
+ private child;
39
+ private ready;
40
+ private shadows;
41
+ private support;
42
+ readonly outDir: string;
43
+ constructor(root: string, tsc: string);
44
+ /** Brings the shadows in line with the pages' latest check units. */
45
+ update(project: Project, units: Map<string, CheckUnit>): Promise<void>;
46
+ /** TypeScript's errors in a page's expressions, at their HTML positions. */
47
+ diagnostics(page: string): Promise<Diagnostic[]>;
48
+ /** The page shadow and shadow position for a 1-based HTML position, if it is in an expression. */
49
+ locate(file: string, line: number, col: number): {
50
+ shadow: OpenShadow;
51
+ line: number;
52
+ col: number;
53
+ } | null;
54
+ /** Maps a shadow range back to the HTML; null when it is not inside one expression. */
55
+ private toHtml;
56
+ hover(file: string, line: number, col: number): Promise<{
57
+ contents: unknown;
58
+ range?: LspRange;
59
+ } | null>;
60
+ /** Completions at a 1-based HTML position inside an expression, with edits mapped to the HTML. */
61
+ completion(file: string, line: number, col: number, context: unknown): Promise<{
62
+ isIncomplete: boolean;
63
+ items: any[];
64
+ } | null>;
65
+ /** Details for a completion item this bridge returned (documentation, signature). */
66
+ resolve(item: any): Promise<any>;
67
+ /**
68
+ * Definitions of the name at a 1-based HTML position: where the HTML declares it (resources,
69
+ * state, loop variables), the spec for types generated from OpenAPI, else TypeScript's own
70
+ * answer (helpers, the types module).
71
+ */
72
+ definition(file: string, line: number, col: number, project: Project): Promise<Target[]>;
73
+ /** Tells TypeScript that files it may have read changed on disk. */
74
+ filesChanged(files: string[]): void;
75
+ dispose(): void;
76
+ }
77
+ export {};
@@ -0,0 +1,242 @@
1
+ // The bridge to TypeScript 7's own language server (`tsc --lsp --stdio`). It keeps
2
+ // one shadow file per page open there, pulls type errors for them and answers hovers,
3
+ // mapping positions between the HTML and the shadows.
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { COMPILER_OPTIONS, shadowOf, shadowPath, typeDiagnostic, writeSupport } from '../check/index.js';
9
+ import { mapPosition, shadowPosition } from '../check/shadow.js';
10
+ import { Connection } from './rpc.js';
11
+ import { specLocation } from './spec-location.js';
12
+ /** The major version of the project's TypeScript, from the package next to its `tsc`. */
13
+ export function typescriptMajor(tsc) {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(join(dirname(tsc), '..', 'package.json'), 'utf8'));
16
+ return Number(/^(\d+)/.exec(pkg.version ?? '')?.[1] ?? 0);
17
+ }
18
+ catch {
19
+ return 0;
20
+ }
21
+ }
22
+ /** Names the shadow declares for itself, never offered as completions. */
23
+ const INTERNAL = /^(\$any|\$item|\$p|\$e\d+)$/;
24
+ export class TypeScriptBridge {
25
+ root;
26
+ conn;
27
+ child;
28
+ ready;
29
+ shadows = new Map();
30
+ support = null;
31
+ outDir;
32
+ constructor(root, tsc) {
33
+ this.root = root;
34
+ this.outDir = join(root, '.sinter', 'lsp');
35
+ mkdirSync(this.outDir, { recursive: true });
36
+ writeFileSync(join(this.outDir, 'tsconfig.json'), JSON.stringify({ compilerOptions: COMPILER_OPTIONS, include: ['**/*.js', '*.d.ts'] }, null, 2) + '\n');
37
+ this.child = spawn(process.execPath, [tsc, '--lsp', '--stdio'], { cwd: this.outDir, stdio: ['pipe', 'pipe', 'ignore'] });
38
+ this.conn = new Connection(this.child.stdout, this.child.stdin);
39
+ // workspace/configuration, client/registerCapability, progress: nothing to say
40
+ this.conn.requestFallback = (method, params) => (method === 'workspace/configuration' ? (params?.items ?? []).map(() => null) : null);
41
+ this.conn.onUnhandled = () => { };
42
+ this.child.on('error', () => this.conn.close());
43
+ this.child.stdin.on('error', () => this.conn.close());
44
+ this.ready = this.conn.request('initialize', {
45
+ processId: process.pid,
46
+ rootUri: pathToFileURL(this.outDir).href,
47
+ capabilities: {
48
+ textDocument: { diagnostic: {}, hover: { contentFormat: ['markdown', 'plaintext'] }, synchronization: {} },
49
+ workspace: { configuration: true, didChangeWatchedFiles: { dynamicRegistration: true } },
50
+ },
51
+ }).then(() => this.conn.notify('initialized', {}), () => { });
52
+ }
53
+ /** Brings the shadows in line with the pages' latest check units. */
54
+ async update(project, units) {
55
+ await this.ready;
56
+ const support = writeSupport({ root: this.root, outDir: this.outDir, types: project.types, openapi: project.openapi });
57
+ // a reloaded project may have new type names or spec declarations
58
+ if (this.support && JSON.stringify(support) !== JSON.stringify(this.support))
59
+ this.filesChanged([support.declarations, ...support.openapi]);
60
+ this.support = support;
61
+ for (const [page, unit] of units) {
62
+ const s = shadowOf(unit, support, this.root, project.helpers, project.actions);
63
+ const prev = this.shadows.get(page);
64
+ if (prev?.text === s.text) {
65
+ prev.unit = unit;
66
+ prev.regions = s.regions;
67
+ prev.brokenFrom = s.brokenFrom;
68
+ continue;
69
+ }
70
+ const path = shadowPath(support, this.root, page);
71
+ const uri = pathToFileURL(path).href;
72
+ const next = { unit, path, uri, text: s.text, lines: s.text.split('\n'), regions: s.regions, brokenFrom: s.brokenFrom, version: (prev?.version ?? 0) + 1 };
73
+ this.shadows.set(page, next);
74
+ if (prev) {
75
+ this.conn.notify('textDocument/didChange', { textDocument: { uri, version: next.version }, contentChanges: [{ text: s.text }] });
76
+ }
77
+ else {
78
+ // on disk too, so the tsconfig's `include` puts it in the project
79
+ mkdirSync(dirname(path), { recursive: true });
80
+ writeFileSync(path, s.text);
81
+ this.conn.notify('textDocument/didOpen', { textDocument: { uri, languageId: 'javascript', version: next.version, text: s.text } });
82
+ }
83
+ }
84
+ for (const [page, s] of this.shadows) {
85
+ if (units.has(page))
86
+ continue;
87
+ this.conn.notify('textDocument/didClose', { textDocument: { uri: s.uri } });
88
+ rmSync(s.path, { force: true });
89
+ this.shadows.delete(page);
90
+ }
91
+ }
92
+ /** TypeScript's errors in a page's expressions, at their HTML positions. */
93
+ async diagnostics(page) {
94
+ const s = this.shadows.get(page);
95
+ if (!s)
96
+ return [];
97
+ const report = await this.conn.request('textDocument/diagnostic', { textDocument: { uri: s.uri } });
98
+ const out = [];
99
+ for (const item of report?.items ?? []) {
100
+ // hints and suggestions (unused imports of the shadow itself) are not the page's business
101
+ if (item.severity !== 1)
102
+ continue;
103
+ const { start, end } = item.range;
104
+ // broken expressions are the compiler's to report
105
+ if (s.brokenFrom !== null && start.line + 1 >= s.brokenFrom)
106
+ continue;
107
+ const d = typeDiagnostic(s.unit, s.regions, start.line + 1, start.character + 1, `TS${item.code}`, item.message, this.root);
108
+ const a = mapPosition(s.regions, start.line + 1, start.character + 1);
109
+ const b = mapPosition(s.regions, end.line + 1, end.character + 1);
110
+ if (a && b && a.file === b.file && (b.line > a.line || (b.line === a.line && b.col > a.col))) {
111
+ d.span = { line: a.line, col: a.col, endLine: b.line, endCol: b.col };
112
+ }
113
+ out.push(d);
114
+ }
115
+ return out;
116
+ }
117
+ /** The page shadow and shadow position for a 1-based HTML position, if it is in an expression. */
118
+ locate(file, line, col) {
119
+ // the page itself first, then pages that include `file`
120
+ const order = [...this.shadows].sort(([a], [b]) => Number(b === file) - Number(a === file));
121
+ for (const [, s] of order) {
122
+ const p = shadowPosition(s.regions, s.lines, file, line, col);
123
+ if (p)
124
+ return { shadow: s, ...p };
125
+ }
126
+ return null;
127
+ }
128
+ /** Maps a shadow range back to the HTML; null when it is not inside one expression. */
129
+ toHtml(s, r, file) {
130
+ const a = mapPosition(s.regions, r.start.line + 1, r.start.character + 1);
131
+ const b = mapPosition(s.regions, r.end.line + 1, r.end.character + 1);
132
+ if (!a || !b || a.file !== file || b.file !== file)
133
+ return null;
134
+ return { start: { line: a.line - 1, character: a.col - 1 }, end: { line: b.line - 1, character: b.col - 1 } };
135
+ }
136
+ async hover(file, line, col) {
137
+ await this.ready;
138
+ const at = this.locate(file, line, col);
139
+ if (!at)
140
+ return null;
141
+ const h = await this.conn.request('textDocument/hover', {
142
+ textDocument: { uri: at.shadow.uri }, position: { line: at.line - 1, character: at.col - 1 },
143
+ });
144
+ if (!h)
145
+ return null;
146
+ const range = h.range ? this.toHtml(at.shadow, h.range, file) : null;
147
+ return range ? { contents: h.contents, range } : { contents: h.contents };
148
+ }
149
+ /** Completions at a 1-based HTML position inside an expression, with edits mapped to the HTML. */
150
+ async completion(file, line, col, context) {
151
+ await this.ready;
152
+ const at = this.locate(file, line, col);
153
+ if (!at)
154
+ return null;
155
+ const r = await this.conn.request('textDocument/completion', {
156
+ textDocument: { uri: at.shadow.uri }, position: { line: at.line - 1, character: at.col - 1 }, context,
157
+ });
158
+ if (!r)
159
+ return null;
160
+ const list = Array.isArray(r) ? r : r.items ?? [];
161
+ const items = [];
162
+ for (const item of list) {
163
+ // the shadow's own names, and auto-imports that would edit the shadow's header
164
+ if (INTERNAL.test(item.label) || item.additionalTextEdits?.length)
165
+ continue;
166
+ const edit = item.textEdit;
167
+ if (edit) {
168
+ const range = edit.range ?? edit.replace;
169
+ const mapped = range && this.toHtml(at.shadow, range, file);
170
+ if (mapped)
171
+ item.textEdit = { range: mapped, newText: edit.newText };
172
+ else
173
+ delete item.textEdit;
174
+ }
175
+ item.data = { sinter: this.root, ts: item.data ?? null, uri: at.shadow.uri };
176
+ items.push(item);
177
+ }
178
+ return { isIncomplete: Array.isArray(r) ? false : !!r.isIncomplete, items };
179
+ }
180
+ /** Details for a completion item this bridge returned (documentation, signature). */
181
+ async resolve(item) {
182
+ const ours = item.data;
183
+ const r = await this.conn.request('completionItem/resolve', { ...item, data: ours.ts, textEdit: undefined });
184
+ return { ...item, documentation: r?.documentation ?? item.documentation, detail: r?.detail ?? item.detail };
185
+ }
186
+ /**
187
+ * Definitions of the name at a 1-based HTML position: where the HTML declares it (resources,
188
+ * state, loop variables), the spec for types generated from OpenAPI, else TypeScript's own
189
+ * answer (helpers, the types module).
190
+ */
191
+ async definition(file, line, col, project) {
192
+ await this.ready;
193
+ const at = this.locate(file, line, col);
194
+ if (!at)
195
+ return [];
196
+ const r = await this.conn.request('textDocument/definition', {
197
+ textDocument: { uri: at.shadow.uri }, position: { line: at.line - 1, character: at.col - 1 },
198
+ });
199
+ const list = !r ? [] : Array.isArray(r) ? r : [r];
200
+ const out = [];
201
+ for (const d of list) {
202
+ const uri = d.targetUri ?? d.uri;
203
+ const range = d.targetSelectionRange ?? d.range;
204
+ if (!uri?.startsWith('file:') || !range)
205
+ continue;
206
+ const path = fileURLToPath(uri);
207
+ const shadow = [...this.shadows.values()].find((s) => s.path === path);
208
+ if (shadow) {
209
+ const loc = mapPosition(shadow.regions, range.start.line + 1, range.start.character + 1);
210
+ if (loc)
211
+ out.push({ file: loc.file, line: loc.line, col: loc.col });
212
+ continue;
213
+ }
214
+ const spec = this.support?.openapi.indexOf(path) ?? -1;
215
+ if (spec >= 0) {
216
+ const text = readFileSync(path, 'utf8').split('\n')[range.start.line] ?? '';
217
+ const name = /^export (?:interface|type) ([\w$]+)/.exec(text)?.[1];
218
+ const ident = text.slice(range.start.character, range.end.character);
219
+ const specFile = project.openapi[spec]?.file;
220
+ const loc = name && specFile ? specLocation(specFile, name, ident !== name ? ident : undefined) : null;
221
+ if (loc && specFile)
222
+ out.push({ file: specFile, ...loc });
223
+ continue;
224
+ }
225
+ // sinter's own declarations say nothing useful about the app
226
+ if (path === this.support?.declarations)
227
+ continue;
228
+ out.push({ file: path, line: range.start.line + 1, col: range.start.character + 1, endLine: range.end.line + 1, endCol: range.end.character + 1 });
229
+ }
230
+ return out;
231
+ }
232
+ /** Tells TypeScript that files it may have read changed on disk. */
233
+ filesChanged(files) {
234
+ if (!files.length)
235
+ return;
236
+ this.conn.notify('workspace/didChangeWatchedFiles', { changes: files.map((f) => ({ uri: pathToFileURL(f).href, type: 2 })) });
237
+ }
238
+ dispose() {
239
+ this.conn.close();
240
+ this.child.kill();
241
+ }
242
+ }