@tiaforge-ui/sinter 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/cache.d.ts +5 -0
- package/dist/cache.js +95 -0
- package/dist/check/index.d.ts +82 -0
- package/dist/check/index.js +156 -0
- package/dist/check/openapi-types.d.ts +6 -0
- package/dist/check/openapi-types.js +84 -0
- package/dist/check/shadow.d.ts +47 -0
- package/dist/check/shadow.js +245 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/compiler/check-unit.d.ts +97 -0
- package/dist/compiler/check-unit.js +3 -0
- package/dist/compiler/compile.d.ts +62 -0
- package/dist/compiler/compile.js +1133 -0
- package/dist/compiler/diagnostic-docs.d.ts +10 -0
- package/dist/compiler/diagnostic-docs.js +137 -0
- package/dist/compiler/diagnostics.d.ts +56 -0
- package/dist/compiler/diagnostics.js +76 -0
- package/dist/compiler/expr.d.ts +89 -0
- package/dist/compiler/expr.js +838 -0
- package/dist/compiler/index.d.ts +21 -0
- package/dist/compiler/index.js +106 -0
- package/dist/compiler/openapi.d.ts +40 -0
- package/dist/compiler/openapi.js +177 -0
- package/dist/compiler/vocabulary.d.ts +12 -0
- package/dist/compiler/vocabulary.js +38 -0
- package/dist/dev.d.ts +10 -0
- package/dist/dev.js +92 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/ir.d.ts +127 -0
- package/dist/ir.js +121 -0
- package/dist/lsp/quickfix.d.ts +15 -0
- package/dist/lsp/quickfix.js +58 -0
- package/dist/lsp/rpc.d.ts +31 -0
- package/dist/lsp/rpc.js +120 -0
- package/dist/lsp/server.d.ts +36 -0
- package/dist/lsp/server.js +281 -0
- package/dist/lsp/spec-location.d.ts +9 -0
- package/dist/lsp/spec-location.js +42 -0
- package/dist/lsp/typescript.d.ts +77 -0
- package/dist/lsp/typescript.js +242 -0
- package/dist/lsp/workspace.d.ts +56 -0
- package/dist/lsp/workspace.js +227 -0
- package/dist/optimistic.d.ts +3 -0
- package/dist/optimistic.js +15 -0
- package/dist/paginate.d.ts +3 -0
- package/dist/paginate.js +184 -0
- package/dist/persist.d.ts +3 -0
- package/dist/persist.js +55 -0
- package/dist/project.d.ts +36 -0
- package/dist/project.js +100 -0
- package/dist/runtime/app.d.ts +125 -0
- package/dist/runtime/app.js +1183 -0
- package/dist/runtime/config.d.ts +53 -0
- package/dist/runtime/config.js +118 -0
- package/dist/runtime/env.d.ts +18 -0
- package/dist/runtime/env.js +23 -0
- package/dist/runtime/helpers.d.ts +9 -0
- package/dist/runtime/helpers.js +55 -0
- package/dist/runtime/request.d.ts +23 -0
- package/dist/runtime/request.js +93 -0
- package/dist/runtime/security.d.ts +12 -0
- package/dist/runtime/security.js +34 -0
- package/dist/runtime/store.d.ts +95 -0
- package/dist/runtime/store.js +142 -0
- package/dist/sse.d.ts +7 -0
- package/dist/sse.js +206 -0
- package/dist/testing/index.d.ts +71 -0
- package/dist/testing/index.js +345 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +94 -0
- package/dist/ws.d.ts +18 -0
- package/dist/ws.js +57 -0
- package/docs/diagnostics.md +273 -0
- package/html-custom-data.json +234 -0
- package/llms.txt +531 -0
- package/package.json +90 -4
|
@@ -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);
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CheckUnit } from '../compiler/check-unit.js';
|
|
2
|
+
import { type Diagnostic } from '../compiler/index.js';
|
|
3
|
+
import { type Project, type ProjectOptions } from '../project.js';
|
|
4
|
+
export interface ProjectState {
|
|
5
|
+
root: string;
|
|
6
|
+
options: ProjectOptions;
|
|
7
|
+
project: Project;
|
|
8
|
+
/** HTML entries from the Vite config */
|
|
9
|
+
entries: Set<string>;
|
|
10
|
+
/** every page compiled so far (entries, plus open HTML files no page includes) */
|
|
11
|
+
pages: Set<string>;
|
|
12
|
+
/** per page: the files it includes */
|
|
13
|
+
includes: Map<string, string[]>;
|
|
14
|
+
/** per page: its last compile's diagnostics */
|
|
15
|
+
diagnostics: Map<string, Diagnostic[]>;
|
|
16
|
+
/** per page: its last compile's check unit */
|
|
17
|
+
units: Map<string, CheckUnit>;
|
|
18
|
+
/** TypeScript's diagnostics, per page (or per check run); they lag behind compiles */
|
|
19
|
+
types: Map<string, Diagnostic[]>;
|
|
20
|
+
/** files whose change reloads the project: Vite config, helpers, types, specs */
|
|
21
|
+
inputs: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
export interface WorkspaceHooks {
|
|
24
|
+
/** the diagnostics of `file` changed (an empty list clears them) */
|
|
25
|
+
publish(file: string, diagnostics: Diagnostic[]): void;
|
|
26
|
+
/** pages were compiled; their check units are in `state.units` */
|
|
27
|
+
compiled?(state: ProjectState, pages: string[]): void;
|
|
28
|
+
}
|
|
29
|
+
export declare class Workspace {
|
|
30
|
+
private hooks;
|
|
31
|
+
/** open buffers by absolute path */
|
|
32
|
+
readonly docs: Map<string, string>;
|
|
33
|
+
readonly projects: Map<string, Promise<ProjectState>>;
|
|
34
|
+
/** files that currently have diagnostics published */
|
|
35
|
+
private published;
|
|
36
|
+
constructor(hooks: WorkspaceHooks);
|
|
37
|
+
/** The project a file belongs to: the nearest folder with a Vite config, else with a package.json. */
|
|
38
|
+
rootOf(file: string): string;
|
|
39
|
+
projectFor(file: string): Promise<ProjectState>;
|
|
40
|
+
private load;
|
|
41
|
+
open(file: string, text: string): Promise<void>;
|
|
42
|
+
change(file: string, text: string): Promise<void>;
|
|
43
|
+
close(file: string): Promise<void>;
|
|
44
|
+
/** A file changed on disk (saved, or changed by another program). */
|
|
45
|
+
fileChanged(file: string): Promise<void>;
|
|
46
|
+
/** a non-HTML file: reload projects that read it */
|
|
47
|
+
private otherChanged;
|
|
48
|
+
private htmlChanged;
|
|
49
|
+
/** the text of a file: the open buffer, else the disk */
|
|
50
|
+
read(file: string): string;
|
|
51
|
+
private compilePages;
|
|
52
|
+
/** Sets TypeScript's diagnostics under `key` (a page) and publishes. Ignored for a replaced project state. */
|
|
53
|
+
setTypes(state: ProjectState, key: string, list: Diagnostic[]): Promise<void>;
|
|
54
|
+
/** Publishes every file's diagnostics, merged across the pages that include it. */
|
|
55
|
+
private publishAll;
|
|
56
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// What the language server knows: the projects under the editor's folders, their
|
|
2
|
+
// pages and partials, open buffers, and the diagnostics of each file. Independent of
|
|
3
|
+
// LSP itself, so tests drive it directly.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { compile } from '../compiler/index.js';
|
|
7
|
+
import { project, viteProject } from '../project.js';
|
|
8
|
+
const VITE_CONFIGS = ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs', 'vite.config.cts', 'vite.config.cjs'];
|
|
9
|
+
export class Workspace {
|
|
10
|
+
hooks;
|
|
11
|
+
/** open buffers by absolute path */
|
|
12
|
+
docs = new Map();
|
|
13
|
+
projects = new Map();
|
|
14
|
+
/** files that currently have diagnostics published */
|
|
15
|
+
published = new Set();
|
|
16
|
+
constructor(hooks) {
|
|
17
|
+
this.hooks = hooks;
|
|
18
|
+
}
|
|
19
|
+
/** The project a file belongs to: the nearest folder with a Vite config, else with a package.json. */
|
|
20
|
+
rootOf(file) {
|
|
21
|
+
let pkg = null;
|
|
22
|
+
for (let dir = dirname(file);; dir = dirname(dir)) {
|
|
23
|
+
if (VITE_CONFIGS.some((c) => existsSync(join(dir, c))))
|
|
24
|
+
return dir;
|
|
25
|
+
if (!pkg && existsSync(join(dir, 'package.json')))
|
|
26
|
+
pkg = dir;
|
|
27
|
+
if (dirname(dir) === dir)
|
|
28
|
+
return pkg ?? dirname(file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
projectFor(file) {
|
|
32
|
+
const root = this.rootOf(file);
|
|
33
|
+
let p = this.projects.get(root);
|
|
34
|
+
if (!p) {
|
|
35
|
+
p = this.load(root);
|
|
36
|
+
this.projects.set(root, p);
|
|
37
|
+
}
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
async load(root) {
|
|
41
|
+
let pages = [];
|
|
42
|
+
let options = {};
|
|
43
|
+
try {
|
|
44
|
+
({ pages, options } = await viteProject(root));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
process.stderr.write(`sinter lsp: reading the Vite config in ${root} failed: ${e.message}\n`);
|
|
48
|
+
}
|
|
49
|
+
const p = project(root, options);
|
|
50
|
+
const inputs = new Set(VITE_CONFIGS.map((c) => join(root, c)));
|
|
51
|
+
for (const f of [p.helpersFile, p.types, ...p.openapi.map((s) => s.file)])
|
|
52
|
+
if (f)
|
|
53
|
+
inputs.add(f);
|
|
54
|
+
for (const spec of options.openapi == null ? [] : [].concat(options.openapi))
|
|
55
|
+
inputs.add(resolve(root, spec));
|
|
56
|
+
for (const ext of ['.js', '.ts']) {
|
|
57
|
+
inputs.add(join(root, 'src/sinter.helpers' + ext));
|
|
58
|
+
inputs.add(join(root, 'src/sinter.types' + ext));
|
|
59
|
+
}
|
|
60
|
+
inputs.add(join(root, 'src/sinter.types.d.ts'));
|
|
61
|
+
for (const m of Object.values(p.helpers.namespaces))
|
|
62
|
+
if (m.module.startsWith('/'))
|
|
63
|
+
inputs.add(join(root, m.module));
|
|
64
|
+
const state = {
|
|
65
|
+
root, options, project: p, entries: new Set(pages), pages: new Set(pages),
|
|
66
|
+
includes: new Map(), diagnostics: new Map(), units: new Map(), types: new Map(), inputs,
|
|
67
|
+
};
|
|
68
|
+
for (const [file] of this.docs)
|
|
69
|
+
if (file.endsWith('.html') && this.rootOf(file) === root)
|
|
70
|
+
state.pages.add(file);
|
|
71
|
+
this.compilePages(state, [...state.pages]);
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
async open(file, text) {
|
|
75
|
+
this.docs.set(file, text);
|
|
76
|
+
if (file.endsWith('.html'))
|
|
77
|
+
await this.htmlChanged(file);
|
|
78
|
+
else
|
|
79
|
+
await this.otherChanged(file);
|
|
80
|
+
}
|
|
81
|
+
async change(file, text) {
|
|
82
|
+
this.docs.set(file, text);
|
|
83
|
+
if (file.endsWith('.html'))
|
|
84
|
+
await this.htmlChanged(file);
|
|
85
|
+
}
|
|
86
|
+
async close(file) {
|
|
87
|
+
this.docs.delete(file);
|
|
88
|
+
if (!file.endsWith('.html'))
|
|
89
|
+
return;
|
|
90
|
+
const state = await this.projectFor(file);
|
|
91
|
+
// a page that is neither an entry nor open is forgotten; its diagnostics go with it
|
|
92
|
+
if (!state.entries.has(file) && state.pages.has(file)) {
|
|
93
|
+
state.pages.delete(file);
|
|
94
|
+
state.includes.delete(file);
|
|
95
|
+
state.diagnostics.delete(file);
|
|
96
|
+
state.units.delete(file);
|
|
97
|
+
state.types.delete(file);
|
|
98
|
+
this.publishAll(state);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await this.htmlChanged(file);
|
|
102
|
+
}
|
|
103
|
+
/** A file changed on disk (saved, or changed by another program). */
|
|
104
|
+
async fileChanged(file) {
|
|
105
|
+
if (file.endsWith('.html')) {
|
|
106
|
+
if (!this.docs.has(file))
|
|
107
|
+
await this.htmlChanged(file);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await this.otherChanged(file);
|
|
111
|
+
}
|
|
112
|
+
/** a non-HTML file: reload projects that read it */
|
|
113
|
+
async otherChanged(file) {
|
|
114
|
+
for (const [root, p] of this.projects) {
|
|
115
|
+
const state = await p;
|
|
116
|
+
if (!state.inputs.has(file))
|
|
117
|
+
continue;
|
|
118
|
+
const next = this.load(root);
|
|
119
|
+
this.projects.set(root, next);
|
|
120
|
+
const fresh = await next;
|
|
121
|
+
// pages the old state knew but the new one doesn't lose their diagnostics
|
|
122
|
+
for (const page of state.pages)
|
|
123
|
+
if (!fresh.pages.has(page))
|
|
124
|
+
state.diagnostics.delete(page);
|
|
125
|
+
this.publishAll(fresh);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async htmlChanged(file) {
|
|
129
|
+
const state = await this.projectFor(file);
|
|
130
|
+
const includers = [...state.includes].filter(([, files]) => files.includes(file)).map(([page]) => page);
|
|
131
|
+
const pages = new Set(includers);
|
|
132
|
+
if (state.pages.has(file) || !includers.length) {
|
|
133
|
+
if (this.docs.has(file) || existsSync(file)) {
|
|
134
|
+
state.pages.add(file);
|
|
135
|
+
pages.add(file);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.compilePages(state, [...pages]);
|
|
139
|
+
}
|
|
140
|
+
/** the text of a file: the open buffer, else the disk */
|
|
141
|
+
read(file) {
|
|
142
|
+
return this.docs.get(file) ?? readFileSync(file, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
compilePages(state, pages) {
|
|
145
|
+
const p = state.project;
|
|
146
|
+
const readFile = (path) => {
|
|
147
|
+
const disk = p.readFile(path); // refuses files outside the root
|
|
148
|
+
return this.docs.get(resolve(path)) ?? disk;
|
|
149
|
+
};
|
|
150
|
+
for (const page of pages) {
|
|
151
|
+
let html;
|
|
152
|
+
try {
|
|
153
|
+
html = this.read(page);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// a page that is now included by another one is a partial, not a page
|
|
159
|
+
if (!state.entries.has(page) && [...state.includes].some(([other, files]) => other !== page && files.includes(page))) {
|
|
160
|
+
state.pages.delete(page);
|
|
161
|
+
state.diagnostics.delete(page);
|
|
162
|
+
state.includes.delete(page);
|
|
163
|
+
state.units.delete(page);
|
|
164
|
+
state.types.delete(page);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const out = compile(html, {
|
|
169
|
+
file: page, root: state.root, dev: false, helpers: p.helpers, config: p.config, readFile, check: true, openapi: p.openapi,
|
|
170
|
+
});
|
|
171
|
+
state.includes.set(page, out.files.map((f) => resolve(f)).filter((f) => f !== page));
|
|
172
|
+
state.diagnostics.set(page, out.diagnostics);
|
|
173
|
+
if (out.check)
|
|
174
|
+
state.units.set(page, out.check);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
state.diagnostics.set(page, [{ severity: 'error', code: 'SN401', message: `sinter could not compile this page: ${e.message}`, file: page, line: 1, col: 1 }]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.publishAll(state);
|
|
181
|
+
this.hooks.compiled?.(state, pages.filter((x) => state.pages.has(x)));
|
|
182
|
+
}
|
|
183
|
+
/** Sets TypeScript's diagnostics under `key` (a page) and publishes. Ignored for a replaced project state. */
|
|
184
|
+
async setTypes(state, key, list) {
|
|
185
|
+
if ((await this.projects.get(state.root)) !== state)
|
|
186
|
+
return;
|
|
187
|
+
state.types.set(key, list);
|
|
188
|
+
this.publishAll(state);
|
|
189
|
+
}
|
|
190
|
+
/** Publishes every file's diagnostics, merged across the pages that include it. */
|
|
191
|
+
publishAll(state) {
|
|
192
|
+
const byFile = new Map();
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
const add = (d) => {
|
|
195
|
+
const key = `${d.file}\0${d.line}\0${d.col}\0${d.code}\0${d.message}`;
|
|
196
|
+
if (seen.has(key))
|
|
197
|
+
return;
|
|
198
|
+
seen.add(key);
|
|
199
|
+
const list = byFile.get(d.file) ?? [];
|
|
200
|
+
list.push(d);
|
|
201
|
+
byFile.set(d.file, list);
|
|
202
|
+
};
|
|
203
|
+
for (const d of state.project.diagnostics)
|
|
204
|
+
add(d);
|
|
205
|
+
for (const page of state.pages)
|
|
206
|
+
for (const d of state.diagnostics.get(page) ?? [])
|
|
207
|
+
add(d);
|
|
208
|
+
for (const list of state.types.values())
|
|
209
|
+
for (const d of list)
|
|
210
|
+
add(d);
|
|
211
|
+
// files of this project that had diagnostics and now have none
|
|
212
|
+
for (const file of this.published) {
|
|
213
|
+
if (byFile.has(file) || this.rootOf(file) !== state.root)
|
|
214
|
+
continue;
|
|
215
|
+
this.published.delete(file);
|
|
216
|
+
this.hooks.publish(file, []);
|
|
217
|
+
}
|
|
218
|
+
for (const [file, list] of byFile) {
|
|
219
|
+
this.published.add(file);
|
|
220
|
+
this.hooks.publish(file, list);
|
|
221
|
+
}
|
|
222
|
+
// open files without problems get an empty list, so editors show them as checked
|
|
223
|
+
for (const file of this.docs.keys())
|
|
224
|
+
if (!byFile.has(file) && this.rootOf(file) === state.root)
|
|
225
|
+
this.hooks.publish(file, []);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const optimistic = {
|
|
2
|
+
name: 'optimistic',
|
|
3
|
+
install(app) {
|
|
4
|
+
app.ext.optimistic = {
|
|
5
|
+
begin(fn, scope) {
|
|
6
|
+
return app.record(() => {
|
|
7
|
+
const s = Object.create(scope);
|
|
8
|
+
s.$f = app.storeFns(null);
|
|
9
|
+
app.fns[fn](s);
|
|
10
|
+
});
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export default optimistic;
|