@weft-js/cli 0.0.0-stage → 0.1.1
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 +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/deploy.md +83 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +366 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +496 -0
- package/src/check.mjs +330 -0
- package/src/cli.mjs +364 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/deploy/cdn.mjs +64 -0
- package/src/deploy/files.mjs +187 -0
- package/src/deploy/fit.mjs +62 -0
- package/src/deploy/hosts.mjs +56 -0
- package/src/deploy/index.mjs +273 -0
- package/src/deploy/needs.mjs +92 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +132 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +303 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +296 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// What one file says about its components, from syntax alone (research 44 §4). No checker here: this runs on every save
|
|
2
|
+
// and is cached by content, so the common file, with nothing to infer, costs a parse. CommonJS because the tsserver
|
|
3
|
+
// plugin loads it synchronously; `ts` is passed in because a plugin must use the TypeScript that hosts it.
|
|
4
|
+
|
|
5
|
+
const { createHash } = require('node:crypto')
|
|
6
|
+
|
|
7
|
+
const hashOf = text => createHash('sha1').update(text).digest('base64url').slice(0, 16)
|
|
8
|
+
const isCap = name => /^[A-Z]/.test(name)
|
|
9
|
+
/** The comment a generated annotation starts with. It keeps the type regenerable; deleting it makes the type yours. */
|
|
10
|
+
const MARK = '/*inferred*/'
|
|
11
|
+
|
|
12
|
+
function isExported(ts, node) {
|
|
13
|
+
return !!ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const walk = (ts, node, visit) => {
|
|
17
|
+
const go = n => { if (visit(n) !== false) ts.forEachChild(n, go) }
|
|
18
|
+
ts.forEachChild(node, go)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A function a component could be: the node, its name, and the parameter it would type. */
|
|
22
|
+
function componentAt(ts, node) {
|
|
23
|
+
if (ts.isFunctionDeclaration(node) && node.name && isCap(node.name.text)) return { fn: node, name: node.name.text, nameNode: node.name, holder: node }
|
|
24
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isCap(node.name.text) && !node.type && node.initializer) {
|
|
25
|
+
const init = node.initializer
|
|
26
|
+
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) return { fn: init, name: node.name.text, nameNode: node.name, holder: node.parent.parent }
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hasJsx = (ts, node) => {
|
|
32
|
+
let found = false
|
|
33
|
+
walk(ts, node, n => {
|
|
34
|
+
if (found) return false
|
|
35
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) found = true
|
|
36
|
+
return !found
|
|
37
|
+
})
|
|
38
|
+
return found
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A parameter pattern's keys: `{ a, b = 1, c: d, ...rest }`. */
|
|
42
|
+
function patternKeys(ts, sf, pattern, out) {
|
|
43
|
+
for (const el of pattern.elements) {
|
|
44
|
+
if (el.dotDotDotToken) { if (ts.isIdentifier(el.name)) out.rest = el.name.text; continue }
|
|
45
|
+
const key = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : ts.isIdentifier(el.name) ? el.name.text : null
|
|
46
|
+
if (!key) { out.opaque = true; continue }
|
|
47
|
+
if (ts.isIdentifier(el.name)) out.aliases.set(el.name.text, key)
|
|
48
|
+
const entry = out.keys.get(key) ?? { uses: [] }
|
|
49
|
+
if (el.initializer) entry.fallback = [el.initializer.getStart(sf), el.initializer.end]
|
|
50
|
+
out.keys.set(key, entry)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads and writes of the prop `key` at `node`, the expression that names it. */
|
|
55
|
+
function classify(ts, sf, node) {
|
|
56
|
+
const up = node.parent
|
|
57
|
+
const span = n => [n.getStart(sf), n.end]
|
|
58
|
+
if ((ts.isPostfixUnaryExpression(up) || ts.isPrefixUnaryExpression(up)) && (up.operator === ts.SyntaxKind.PlusPlusToken || up.operator === ts.SyntaxKind.MinusMinusToken)) return { as: 'number', span: span(node) }
|
|
59
|
+
if (ts.isBinaryExpression(up) && up.left === node) {
|
|
60
|
+
const op = up.operatorToken.kind
|
|
61
|
+
if (op === ts.SyntaxKind.EqualsToken || op === ts.SyntaxKind.PlusEqualsToken) return { as: 'write', span: span(node), value: span(up.right) }
|
|
62
|
+
if (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) return { as: 'number', span: span(node) }
|
|
63
|
+
}
|
|
64
|
+
if (ts.isCallExpression(up) && up.expression === node) return { as: 'call', span: span(node), args: up.arguments.map(span) }
|
|
65
|
+
if ((ts.isPropertyAccessExpression(up) && up.expression === node) || (ts.isNonNullExpression(up))) {
|
|
66
|
+
const member = ts.isPropertyAccessExpression(up) ? up.name.text : null
|
|
67
|
+
if (member) return { as: 'member', span: span(up), member }
|
|
68
|
+
}
|
|
69
|
+
if (ts.isJsxExpression(up) && ts.isJsxAttribute(up.parent)) return { as: 'context', span: span(node) }
|
|
70
|
+
return { as: 'read', span: span(node) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every use of the props in a component's body. */
|
|
74
|
+
function bodyUses(ts, sf, comp) {
|
|
75
|
+
const { fn, param, info } = comp
|
|
76
|
+
const names = new Set(ts.isIdentifier(param.name) ? [param.name.text] : [])
|
|
77
|
+
const use = (key, node) => {
|
|
78
|
+
const entry = info.keys.get(key) ?? { uses: [] }
|
|
79
|
+
entry.uses.push(classify(ts, sf, node))
|
|
80
|
+
info.keys.set(key, entry)
|
|
81
|
+
}
|
|
82
|
+
const shadows = (node, name) => node.parameters?.some(p => ts.isIdentifier(p.name) && p.name.text === name)
|
|
83
|
+
const visit = (node, scope) => {
|
|
84
|
+
// `const { a } = props`, `let n = props.n`: another name for the prop (research 43). Only the first level.
|
|
85
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
86
|
+
const init = node.initializer
|
|
87
|
+
if (ts.isIdentifier(init) && scope.has(init.text) && ts.isObjectBindingPattern(node.name)) patternKeys(ts, sf, node.name, info)
|
|
88
|
+
else if (ts.isPropertyAccessExpression(init) && ts.isIdentifier(init.expression) && scope.has(init.expression.text) && ts.isIdentifier(node.name)) {
|
|
89
|
+
info.aliases.set(node.name.text, init.name.text)
|
|
90
|
+
if (!info.keys.has(init.name.text)) info.keys.set(init.name.text, { uses: [] })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && scope.has(node.expression.text)) return use(node.name.text, node)
|
|
95
|
+
if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression) && scope.has(node.expression.text)) {
|
|
96
|
+
if (ts.isStringLiteralLike(node.argumentExpression)) return use(node.argumentExpression.text, node)
|
|
97
|
+
info.opaque = true
|
|
98
|
+
}
|
|
99
|
+
if (ts.isIdentifier(node) && info.aliases.has(node.text) && ts.isExpressionNode?.(node) !== false) {
|
|
100
|
+
const up = node.parent
|
|
101
|
+
const named = (ts.isPropertyAccessExpression(up) && up.name === node) || (ts.isPropertyAssignment(up) && up.name === node) || ts.isBindingElement(up) || (ts.isVariableDeclaration(up) && up.name === node) || ts.isJsxAttribute(up)
|
|
102
|
+
if (!named) use(info.aliases.get(node.text), node)
|
|
103
|
+
}
|
|
104
|
+
if (ts.isFunctionLike(node)) {
|
|
105
|
+
let inner = scope
|
|
106
|
+
for (const n of scope) if (shadows(node, n)) { inner = new Set(scope); inner.delete(n) }
|
|
107
|
+
ts.forEachChild(node, c => visit(c, inner))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
ts.forEachChild(node, c => visit(c, scope))
|
|
111
|
+
}
|
|
112
|
+
if (fn.body) visit(fn.body, names)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function jsxSite(ts, sf, node, enclosing) {
|
|
116
|
+
const tag = node.tagName
|
|
117
|
+
if (!ts.isIdentifier(tag) && !ts.isPropertyAccessExpression(tag)) return null
|
|
118
|
+
if (ts.isIdentifier(tag) && !isCap(tag.text)) return null
|
|
119
|
+
const span = n => [n.getStart(sf), n.end]
|
|
120
|
+
const site = { tag: tag.getText(sf), tagSpan: span(tag), span: span(node), attrs: [], spreads: [], children: null, in: enclosing ?? null }
|
|
121
|
+
for (const a of node.attributes.properties) {
|
|
122
|
+
if (ts.isJsxSpreadAttribute(a)) { site.spreads.push({ span: span(a.expression), text: a.expression.getText(sf) }); continue }
|
|
123
|
+
const raw = a.name.getText(sf)
|
|
124
|
+
const bind = raw.startsWith('bind:')
|
|
125
|
+
const name = bind ? raw.slice(5) : raw
|
|
126
|
+
if (name === 'key') continue
|
|
127
|
+
const value = a.initializer
|
|
128
|
+
if (!value) { site.attrs.push({ name, literal: 'true', bind }); continue }
|
|
129
|
+
if (ts.isStringLiteral(value)) { site.attrs.push({ name, literal: 'string', exact: JSON.stringify(value.text), whole: span(a), bind }); continue }
|
|
130
|
+
const expr = value.expression
|
|
131
|
+
if (!expr) continue
|
|
132
|
+
const lambda = ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)
|
|
133
|
+
site.attrs.push({
|
|
134
|
+
name, span: span(expr), whole: span(a), bind, raw: bind ? span(a.name) : undefined,
|
|
135
|
+
untypedFn: lambda && expr.parameters.some(p => !p.type) || undefined,
|
|
136
|
+
value: ts.isIdentifier(expr) && isCap(expr.text) ? expr.text : undefined,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
if (ts.isJsxOpeningElement(node)) {
|
|
140
|
+
const kids = node.parent.children.filter(c => !(ts.isJsxText(c) && !c.text.trim()))
|
|
141
|
+
if (kids.length) {
|
|
142
|
+
const only = kids.length === 1 ? kids[0] : null
|
|
143
|
+
const expr = only && ts.isJsxExpression(only) ? only.expression : null
|
|
144
|
+
const lambda = expr && (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr))
|
|
145
|
+
site.children = lambda ? { fn: true, span: span(expr), untypedFn: expr.parameters.some(p => !p.type) || undefined } : { fn: false }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return site
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `const views = { a: A }`, `pick(A, B)`, `<X icon={A} />`: an identifier that leaves JSX tag position. */
|
|
152
|
+
function escapes(ts, node) {
|
|
153
|
+
const up = node.parent
|
|
154
|
+
if (ts.isJsxOpeningLikeElement?.(up) && up.tagName === node) return false
|
|
155
|
+
if (ts.isJsxClosingElement(up)) return false
|
|
156
|
+
if (ts.isFunctionDeclaration(up) || ts.isVariableDeclaration(up) && up.name === node) return false
|
|
157
|
+
if (ts.isPropertyAccessExpression(up) && up.name === node) return false
|
|
158
|
+
if (ts.isExportSpecifier(up) || ts.isExportAssignment(up) || ts.isImportSpecifier(up) || ts.isImportClause(up)) return false
|
|
159
|
+
if (ts.isTypeQueryNode(up)) return false
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The names an initializer can hand back: `pick(A, B)`, `{ a: A, b: B }`, `views[k]`, `views.a`, `hoc(A)`, `x ? A : B`. */
|
|
164
|
+
function holds(ts, init) {
|
|
165
|
+
const name = e => (ts.isIdentifier(e) ? [e.text] : [])
|
|
166
|
+
if (ts.isCallExpression(init)) return init.arguments.flatMap(a => name(a)).filter(isCap)
|
|
167
|
+
if (ts.isConditionalExpression(init)) return [...name(init.whenTrue), ...name(init.whenFalse)].filter(isCap)
|
|
168
|
+
if (ts.isObjectLiteralExpression(init)) return init.properties.flatMap(p => (ts.isPropertyAssignment(p) ? name(p.initializer) : ts.isShorthandPropertyAssignment(p) ? [p.name.text] : [])).filter(isCap)
|
|
169
|
+
if (ts.isElementAccessExpression(init) || ts.isPropertyAccessExpression(init)) return name(init.expression)
|
|
170
|
+
return []
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const isOpening = (ts, n) => ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @returns the file's facts: `comps` (untyped or generated), `sites` (capitalised JSX tags), `imports`, `wraps`
|
|
177
|
+
* (`const W = hoc(Inner)`), `escaped` (component names used as values), `exports` (local names).
|
|
178
|
+
*/
|
|
179
|
+
function fileFacts(ts, file, text, options = {}) {
|
|
180
|
+
const hash = hashOf(text)
|
|
181
|
+
if (!file.endsWith('.tsx')) return { file, hash, comps: [], sites: [], imports: new Map(), wraps: new Map(), escaped: new Set(), exported: new Set(), parse: null }
|
|
182
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
183
|
+
const comps = [], sites = []
|
|
184
|
+
const imports = new Map(), wraps = new Map(), escaped = new Set(), exported = new Set()
|
|
185
|
+
let exportsLoad = false
|
|
186
|
+
|
|
187
|
+
for (const st of sf.statements) {
|
|
188
|
+
if (ts.isImportDeclaration(st) && st.importClause && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
189
|
+
const from = st.moduleSpecifier.text
|
|
190
|
+
const { name, namedBindings } = st.importClause
|
|
191
|
+
if (name) imports.set(name.text, { from, name: 'default' })
|
|
192
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) for (const el of namedBindings.elements) imports.set(el.name.text, { from, name: (el.propertyName ?? el.name).text })
|
|
193
|
+
}
|
|
194
|
+
if (ts.isExportAssignment(st) && ts.isIdentifier(st.expression)) exported.add(st.expression.text)
|
|
195
|
+
if (ts.isExportDeclaration(st) && st.exportClause && ts.isNamedExports(st.exportClause) && !st.moduleSpecifier) for (const el of st.exportClause.elements) exported.add((el.propertyName ?? el.name).text)
|
|
196
|
+
if (isExported(ts, st) && ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === 'load') exportsLoad = true
|
|
197
|
+
if (isExported(ts, st) && ts.isFunctionDeclaration(st) && st.name?.text === 'load') exportsLoad = true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const enclosing = []
|
|
201
|
+
const visit = node => {
|
|
202
|
+
const found = componentAt(ts, node)
|
|
203
|
+
if (found) {
|
|
204
|
+
const { fn, name } = found
|
|
205
|
+
const param = fn.parameters?.[0]
|
|
206
|
+
const typed = param?.type
|
|
207
|
+
const generated = typed && text.slice(param.name.end, typed.pos + 64).includes(MARK)
|
|
208
|
+
if (param && fn.parameters.length === 1 && (!typed || generated) && !param.questionToken && !param.dotDotDotToken && !fn.typeParameters && hasJsx(ts, fn)) {
|
|
209
|
+
const info = { keys: new Map(), aliases: new Map(), rest: null, opaque: false }
|
|
210
|
+
if (ts.isObjectBindingPattern(param.name)) patternKeys(ts, sf, param.name, info)
|
|
211
|
+
else if (!ts.isIdentifier(param.name)) info.opaque = true
|
|
212
|
+
const comp = {
|
|
213
|
+
id: `${file}#${name}`, file, name, param, fn, info, nameSpan: [found.nameNode.getStart(sf), found.nameNode.end],
|
|
214
|
+
bodySpan: [fn.getStart(sf), fn.end], exported: isExported(ts, found.holder), isDefault: (ts.canHaveModifiers(found.holder) ? ts.getModifiers(found.holder) ?? [] : []).some(m => m.kind === ts.SyntaxKind.DefaultKeyword),
|
|
215
|
+
paramSpan: [param.getStart(sf), param.end], nameEnd: param.name.end, parens: !!fn.getChildren?.(sf).some(c => c.kind === ts.SyntaxKind.OpenParenToken),
|
|
216
|
+
written: generated ? [param.name.end, typed.end] : null, writtenType: generated ? text.slice(typed.getStart(sf), typed.end).trim() : null,
|
|
217
|
+
forwards: [], local: enclosing.length > 0, async: !!(ts.getModifiers?.(fn) ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
|
|
218
|
+
}
|
|
219
|
+
bodyUses(ts, sf, comp)
|
|
220
|
+
comps.push(comp)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
224
|
+
const held = holds(ts, node.initializer)
|
|
225
|
+
if (held.length) wraps.set(node.name.text, held)
|
|
226
|
+
}
|
|
227
|
+
if (isOpening(ts, node)) {
|
|
228
|
+
const site = jsxSite(ts, sf, node, enclosing[enclosing.length - 1])
|
|
229
|
+
if (site) sites.push(site)
|
|
230
|
+
}
|
|
231
|
+
if (ts.isIdentifier(node) && isCap(node.text) && node.parent && !ts.isTypeReferenceNode(node.parent) && escapes(ts, node)) escaped.add(node.text)
|
|
232
|
+
const push = found && found.fn
|
|
233
|
+
if (push) enclosing.push(found.name)
|
|
234
|
+
ts.forEachChild(node, visit)
|
|
235
|
+
if (push) enclosing.pop()
|
|
236
|
+
}
|
|
237
|
+
ts.forEachChild(sf, visit)
|
|
238
|
+
|
|
239
|
+
for (const c of comps) if (exported.has(c.name) || c.isDefault) { c.exported = true; c.isDefault ||= false }
|
|
240
|
+
// Spreads and attributes forward from a component to whatever it renders.
|
|
241
|
+
for (const c of comps) {
|
|
242
|
+
const own = new Set([...(ts.isIdentifier(c.param.name) ? [c.param.name.text] : []), ...(c.info.rest ? [c.info.rest] : [])])
|
|
243
|
+
for (const s of sites) {
|
|
244
|
+
if (s.span[0] < c.fn.getStart(sf) || s.span[1] > c.fn.end) continue
|
|
245
|
+
for (const sp of s.spreads) if (own.has(sp.text)) c.forwards.push({ tag: s.tag, tagSpan: s.tagSpan, given: s.attrs.map(a => a.name) })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const c of comps) { delete c.fn; delete c.param }
|
|
249
|
+
return { file, hash, comps, sites, imports, wraps, escaped, exported, exportsLoad, parse: options.keep ? sf : null }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { fileFacts, hashOf, MARK }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The two ways to ask a checker about a shadow. Both answer the same four questions about a span (`query`), so the
|
|
2
|
+
// engine doesn't know which compiler it is on. `tsgoHost` drives TypeScript 7's process through its unstable API with a
|
|
3
|
+
// file overlay: `tsc --noEmit` of the source tree, with no files on disk and no plugin. `languageHost` reuses a language
|
|
4
|
+
// service, which is what a tsserver plugin has, and what a project on TypeScript 6 gets.
|
|
5
|
+
|
|
6
|
+
const NO_TRUNCATE = 1
|
|
7
|
+
|
|
8
|
+
/** The node exactly on [start, end]: the deepest one, because `x` and `x;` can share an end but not a start. */
|
|
9
|
+
function nodeAt(each, root, start, end) {
|
|
10
|
+
let best = null
|
|
11
|
+
const go = n => {
|
|
12
|
+
if (n.pos <= start && n.end >= end) {
|
|
13
|
+
if (n.end === end) best = n
|
|
14
|
+
each(n, go)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
each(root, go)
|
|
18
|
+
return best
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The flags the engine reads. `lit`: the widened text differs from the exact one. */
|
|
22
|
+
function describe(checker, type, flagsAny, node) {
|
|
23
|
+
if (!type) return { text: 'any', any: true }
|
|
24
|
+
const widened = checker.getWidenedType?.(checker.getBaseTypeOfLiteralType(type)) ?? checker.getBaseTypeOfLiteralType(type)
|
|
25
|
+
const text = checker.typeToString(widened, node, NO_TRUNCATE)
|
|
26
|
+
const exact = checker.typeToString(type, node, NO_TRUNCATE)
|
|
27
|
+
return { text, exact: exact === text ? undefined : exact, any: !!(type.flags & flagsAny) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function answer(checker, node, req, env) {
|
|
31
|
+
if (!node) return { text: 'any', any: true, missing: true }
|
|
32
|
+
switch (req.kind) {
|
|
33
|
+
case 'type': return describe(checker, checker.getTypeAtLocation(node), env.Any, node)
|
|
34
|
+
case 'context': return describe(checker, checker.getContextualType(node), env.Any, node)
|
|
35
|
+
case 'spread': {
|
|
36
|
+
const type = checker.getTypeAtLocation(node)
|
|
37
|
+
if (!type || type.flags & env.Any) return { any: true, props: [] }
|
|
38
|
+
return { props: checker.getPropertiesOfType(type).map(s => ({ name: s.name, optional: !!(s.flags & env.Optional), ...describe(checker, checker.getTypeOfSymbol(s), env.Any, node) })) }
|
|
39
|
+
}
|
|
40
|
+
case 'decls': {
|
|
41
|
+
const type = checker.getTypeAtLocation(node)
|
|
42
|
+
if (!type) return { decls: [] }
|
|
43
|
+
const members = env.union(type) ?? [type]
|
|
44
|
+
return { decls: members.flatMap(t => checker.getSignaturesOfType(t, env.Call).map(s => env.place(s))).filter(Boolean) }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Over TypeScript 7's API. `typescript/unstable/*` is a moving name, so this is the one file that touches it. */
|
|
50
|
+
async function tsgoHost({ root, config, typescriptDir, overlay }) {
|
|
51
|
+
const load = name => import(require('node:url').pathToFileURL(require('node:path').join(typescriptDir, name)).href)
|
|
52
|
+
const { API, SignatureKind, SymbolFlags, TypeFlags } = await load('dist/api/sync/api.js')
|
|
53
|
+
const each = (n, f) => n.forEachChild(f)
|
|
54
|
+
const api = new API({ cwd: root, fs: { readFile: f => overlay.get(f) } })
|
|
55
|
+
let snap = api.updateSnapshot({ openProject: config })
|
|
56
|
+
let project = snap.getProject(config) ?? snap.getProjects()[0]
|
|
57
|
+
const env = {
|
|
58
|
+
Any: TypeFlags.Any, Optional: SymbolFlags.Optional, Call: SignatureKind.Call,
|
|
59
|
+
union: t => (t.isUnionType() ? t.getTypes() : null),
|
|
60
|
+
place(s) { const n = s.declaration?.resolve(project); return n && { file: s.declaration.path, pos: n.pos, end: n.end } },
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
kind: 'tsgo',
|
|
64
|
+
update(files, touched = []) {
|
|
65
|
+
for (const [f, text] of files) overlay.set(f, text)
|
|
66
|
+
const next = api.updateSnapshot({ fileChanges: { changed: [...files.keys(), ...touched] } })
|
|
67
|
+
snap.dispose?.()
|
|
68
|
+
snap = next
|
|
69
|
+
project = snap.getProject(config) ?? snap.getProjects()[0]
|
|
70
|
+
},
|
|
71
|
+
query(file, reqs) {
|
|
72
|
+
const sf = project.program.getSourceFile(file)
|
|
73
|
+
if (!sf) return reqs.map(() => ({ text: 'any', any: true, missing: true }))
|
|
74
|
+
return reqs.map(r => answer(project.checker, nodeAt(each, sf, r.start, r.end), r, env))
|
|
75
|
+
},
|
|
76
|
+
diagnostics(file) {
|
|
77
|
+
return [...project.program.getSyntacticDiagnostics(file), ...project.program.getSemanticDiagnostics(file)].map(d => ({ start: d.pos, length: d.end - d.pos, code: d.code, text: flatten(d) }))
|
|
78
|
+
},
|
|
79
|
+
allDiagnostics() {
|
|
80
|
+
return project.program.getSourceFileNames().filter(f => !f.includes('/node_modules/')).flatMap(f => this.diagnostics(f).map(d => ({ ...d, file: f })))
|
|
81
|
+
},
|
|
82
|
+
close() { snap.dispose?.(); api.close() },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const flatten = d => (typeof d.text === 'string' ? d.text : d.message ?? d.messageText ?? String(d.text)) + (d.messageChain?.length ? `\n ${d.messageChain.map(flatten).join('\n ')}` : '')
|
|
87
|
+
|
|
88
|
+
/** Over a TypeScript language service: `service()` returns it, `sync()` makes it see what `update` was told. */
|
|
89
|
+
function languageHost(ts, service, sync = () => {}) {
|
|
90
|
+
const env = {
|
|
91
|
+
Any: ts.TypeFlags.Any, Optional: ts.SymbolFlags.Optional, Call: ts.SignatureKind.Call,
|
|
92
|
+
union: t => (t.isUnion() ? t.types : null),
|
|
93
|
+
place(s) { const n = s.declaration; return n && { file: n.getSourceFile().fileName, pos: n.pos, end: n.end } },
|
|
94
|
+
}
|
|
95
|
+
const each = (n, f) => ts.forEachChild(n, f)
|
|
96
|
+
return {
|
|
97
|
+
kind: 'language-service',
|
|
98
|
+
update(files, touched = []) { sync(files, touched) },
|
|
99
|
+
query(file, reqs) {
|
|
100
|
+
const program = service().getProgram()
|
|
101
|
+
const sf = program.getSourceFile(file)
|
|
102
|
+
if (!sf) return reqs.map(() => ({ text: 'any', any: true, missing: true }))
|
|
103
|
+
const checker = program.getTypeChecker()
|
|
104
|
+
return reqs.map(r => answer(checker, nodeAt(each, sf, r.start, r.end), r, env))
|
|
105
|
+
},
|
|
106
|
+
diagnostics(file) {
|
|
107
|
+
const s = service()
|
|
108
|
+
return [...s.getSyntacticDiagnostics(file), ...s.getSemanticDiagnostics(file)].map(d => ({ start: d.start ?? 0, length: d.length ?? 0, code: d.code, text: ts.flattenDiagnosticMessageText(d.messageText, '\n') }))
|
|
109
|
+
},
|
|
110
|
+
close() {},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { tsgoHost, languageHost, nodeAt }
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `weft lsp`: TypeScript 7 loads no plugins and its content mappers refuse `.tsx`, so the one place left to stand is
|
|
2
|
+
// between the editor and `tsc --lsp`. Buffers go in as their shadows; positions are mapped both ways (research 44 §6).
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { realpathSync } from 'node:fs'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import ts from 'typescript'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
const { Session, projectTypescript } = require('./session.cjs')
|
|
13
|
+
const { inGenerated, toShadow, toSource } = require('./shadow.cjs')
|
|
14
|
+
|
|
15
|
+
const RANGES = new Set(['range', 'selectionRange', 'targetRange', 'targetSelectionRange', 'originSelectionRange', 'insert', 'replace'])
|
|
16
|
+
|
|
17
|
+
function framed(stream, on) {
|
|
18
|
+
let buffer = Buffer.alloc(0)
|
|
19
|
+
stream.on('data', d => {
|
|
20
|
+
buffer = Buffer.concat([buffer, d])
|
|
21
|
+
for (;;) {
|
|
22
|
+
const head = buffer.indexOf('\r\n\r\n')
|
|
23
|
+
if (head < 0) return
|
|
24
|
+
const len = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, head).toString())[1])
|
|
25
|
+
if (buffer.length < head + 4 + len) return
|
|
26
|
+
const msg = JSON.parse(buffer.subarray(head + 4, head + 4 + len).toString())
|
|
27
|
+
buffer = buffer.subarray(head + 4 + len)
|
|
28
|
+
on(msg)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
const frame = msg => { const body = Buffer.from(JSON.stringify(msg)); return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) }
|
|
33
|
+
|
|
34
|
+
const offsetAt = (text, { line, character }) => {
|
|
35
|
+
let at = 0
|
|
36
|
+
for (let i = 0; i < line; i++) { const n = text.indexOf('\n', at); if (n < 0) return text.length; at = n + 1 }
|
|
37
|
+
return at + character
|
|
38
|
+
}
|
|
39
|
+
const positionAt = (text, offset) => {
|
|
40
|
+
const head = text.slice(0, offset)
|
|
41
|
+
const line = head.split('\n').length - 1
|
|
42
|
+
return { line, character: offset - (head.lastIndexOf('\n') + 1) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function lsp(root, { input = process.stdin, output = process.stdout } = {}) {
|
|
46
|
+
const dir = projectTypescript(root)
|
|
47
|
+
if (!dir) throw Object.assign(new Error('`weft lsp` needs TypeScript 7 in the project; older versions use the tsserver plugin'), { code: 'E_USAGE' })
|
|
48
|
+
const exe = join(dirname(createRequire(join(realpathSync(dir), 'package.json')).resolve(`@typescript/typescript-${process.platform}-${process.arch}/package.json`)), 'lib', process.platform === 'win32' ? 'tsc.exe' : 'tsc')
|
|
49
|
+
const server = spawn(exe, ['--lsp', '--stdio'], { cwd: root, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
50
|
+
const session = new Session(ts, root)
|
|
51
|
+
const docs = new Map() // uri → { file, text, sent, version }
|
|
52
|
+
const pending = new Map() // request id → uri
|
|
53
|
+
const toServer = m => server.stdin.write(frame(m))
|
|
54
|
+
const toClient = m => output.write(frame(m))
|
|
55
|
+
const fileOf = uri => fileURLToPath(uri)
|
|
56
|
+
const mapOf = doc => doc && session.engine.maps.get(doc.file)
|
|
57
|
+
|
|
58
|
+
const pos = (doc, p, to) => {
|
|
59
|
+
const map = mapOf(doc)
|
|
60
|
+
if (!map) return p
|
|
61
|
+
return to ? positionAt(map.text, toShadow(map, offsetAt(doc.text, p))) : positionAt(doc.text, toSource(map, offsetAt(map.text, p)))
|
|
62
|
+
}
|
|
63
|
+
const back = (value, doc) => {
|
|
64
|
+
if (Array.isArray(value)) return value.map(v => back(v, doc))
|
|
65
|
+
if (!value || typeof value !== 'object') return value
|
|
66
|
+
const uri = value.uri ?? value.targetUri
|
|
67
|
+
const here = uri ? docs.get(uri) ?? doc : doc
|
|
68
|
+
const out = {}
|
|
69
|
+
for (const [k, v] of Object.entries(value)) {
|
|
70
|
+
if (RANGES.has(k) && v?.start && mapOf(here)) out[k] = { start: pos(here, v.start), end: pos(here, v.end) }
|
|
71
|
+
else out[k] = back(v, here)
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
const generated = (doc, d) => { const map = mapOf(doc); if (!map) return false; const a = offsetAt(map.text, d.range.start); return inGenerated(map, a, offsetAt(map.text, d.range.end) - a) }
|
|
76
|
+
const ours = doc => (session.engine.findings.get(doc.file) ?? []).map(f => ({
|
|
77
|
+
range: { start: positionAt(doc.text, f.start), end: positionAt(doc.text, f.end) }, severity: f.severity === 'warning' ? 2 : 1, code: f.code, source: 'weft', message: `${f.why} Fix: ${f.help}`,
|
|
78
|
+
}))
|
|
79
|
+
const items = (doc, list) => [...back(list.filter(d => !generated(doc, d)), doc).filter(d => !(session.engine.findings.get(doc.file) ?? []).some(f => f.code === 'W084' && offsetAt(doc.text, d.range.start) < f.end && f.start <= offsetAt(doc.text, d.range.start))), ...ours(doc)]
|
|
80
|
+
|
|
81
|
+
/** Inference over the buffers, then every open file whose shadow moved. */
|
|
82
|
+
async function refresh(changed) {
|
|
83
|
+
if (session.host) session.host.update(new Map(changed.flatMap(f => (session.engine.maps.has(f) ? [] : [[f, session.engine.texts.get(f)]]))))
|
|
84
|
+
await session.analyze(session.host ? changed : undefined)
|
|
85
|
+
for (const [uri, doc] of docs) {
|
|
86
|
+
const text = session.engine.maps.get(doc.file)?.text ?? doc.text
|
|
87
|
+
if (text === doc.sent) continue
|
|
88
|
+
doc.sent = text
|
|
89
|
+
toServer({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: ++doc.version }, contentChanges: [{ text }] } })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One at a time: a request has to see the shadow its position is mapped for.
|
|
94
|
+
let line = Promise.resolve()
|
|
95
|
+
framed(input, msg => { line = line.then(() => client(msg)).catch(e => process.stderr.write(`weft lsp: ${e.stack}\n`)) })
|
|
96
|
+
async function client(msg) {
|
|
97
|
+
const p = msg.params
|
|
98
|
+
if (msg.method === 'textDocument/didOpen') {
|
|
99
|
+
const doc = { file: fileOf(p.textDocument.uri), text: p.textDocument.text, version: p.textDocument.version }
|
|
100
|
+
docs.set(p.textDocument.uri, doc)
|
|
101
|
+
session.engine.set(doc.file, doc.text)
|
|
102
|
+
await refresh([doc.file])
|
|
103
|
+
doc.sent = session.engine.maps.get(doc.file)?.text ?? doc.text
|
|
104
|
+
return toServer({ ...msg, params: { textDocument: { ...p.textDocument, text: doc.sent } } })
|
|
105
|
+
}
|
|
106
|
+
if (msg.method === 'textDocument/didChange') {
|
|
107
|
+
const doc = docs.get(p.textDocument.uri)
|
|
108
|
+
for (const c of p.contentChanges) doc.text = c.range ? doc.text.slice(0, offsetAt(doc.text, c.range.start)) + c.text + doc.text.slice(offsetAt(doc.text, c.range.end)) : c.text
|
|
109
|
+
session.engine.set(doc.file, doc.text)
|
|
110
|
+
return refresh([doc.file])
|
|
111
|
+
}
|
|
112
|
+
const doc = p?.textDocument && docs.get(p.textDocument.uri)
|
|
113
|
+
if (msg.id !== undefined && doc) pending.set(msg.id, { doc, method: msg.method })
|
|
114
|
+
if (doc && p.position) return toServer({ ...msg, params: { ...p, position: pos(doc, p.position, true) } })
|
|
115
|
+
toServer(msg)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
framed(server.stdout, msg => {
|
|
119
|
+
if (msg.method === 'textDocument/publishDiagnostics') {
|
|
120
|
+
const doc = docs.get(msg.params.uri)
|
|
121
|
+
return toClient(doc ? { ...msg, params: { ...msg.params, diagnostics: items(doc, msg.params.diagnostics) } } : msg)
|
|
122
|
+
}
|
|
123
|
+
const hold = msg.id !== undefined && !msg.method ? pending.get(msg.id) : null
|
|
124
|
+
if (!hold) return toClient(msg)
|
|
125
|
+
pending.delete(msg.id)
|
|
126
|
+
if (hold.method === 'textDocument/diagnostic' && msg.result?.items) return toClient({ ...msg, result: { ...msg.result, items: items(hold.doc, msg.result.items) } })
|
|
127
|
+
toClient({ ...msg, result: back(msg.result, hold.doc) })
|
|
128
|
+
})
|
|
129
|
+
server.on('exit', code => { session.close(); process.exitCode = code ?? 0 })
|
|
130
|
+
return { server, close: () => { server.kill(); session.close() } }
|
|
131
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// One project's inference: a tsconfig's files, an engine, and a checker started only if something needs one. `weft
|
|
2
|
+
// check`, `weft tsc` and the dev worker all use this. The tsserver plugin has a language service of its own (plugin).
|
|
3
|
+
|
|
4
|
+
const { existsSync, readFileSync } = require('node:fs')
|
|
5
|
+
const { createRequire } = require('node:module')
|
|
6
|
+
const { dirname, join } = require('node:path')
|
|
7
|
+
const { Engine } = require('./engine.cjs')
|
|
8
|
+
const { languageHost, tsgoHost } = require('./hosts.cjs')
|
|
9
|
+
|
|
10
|
+
/** The project's TypeScript when it can be driven without a plugin (7's API), else ours. */
|
|
11
|
+
function projectTypescript(root) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = createRequire(join(root, 'package.json')).resolve('typescript/package.json')
|
|
14
|
+
const dir = dirname(pkg)
|
|
15
|
+
if (existsSync(join(dir, 'dist/api/sync/api.js'))) return dir
|
|
16
|
+
} catch {}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A language service over the project and an overlay: TypeScript before 7. */
|
|
21
|
+
function memoryHost(ts, root, config, parsed, overlay) {
|
|
22
|
+
const versions = new Map()
|
|
23
|
+
const host = {
|
|
24
|
+
getCompilationSettings: () => parsed.options,
|
|
25
|
+
getScriptFileNames: () => parsed.fileNames,
|
|
26
|
+
getScriptVersion: f => String(versions.get(f) ?? 0),
|
|
27
|
+
getScriptSnapshot(f) { const t = overlay.get(f) ?? (existsSync(f) ? readFileSync(f, 'utf8') : undefined); return t === undefined ? undefined : ts.ScriptSnapshot.fromString(t) },
|
|
28
|
+
getCurrentDirectory: () => root,
|
|
29
|
+
getDefaultLibFileName: o => ts.getDefaultLibFilePath(o),
|
|
30
|
+
fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory, directoryExists: ts.sys.directoryExists, getDirectories: ts.sys.getDirectories,
|
|
31
|
+
}
|
|
32
|
+
const service = ts.createLanguageService(host)
|
|
33
|
+
const bump = f => versions.set(f, (versions.get(f) ?? 0) + 1)
|
|
34
|
+
const out = languageHost(ts, () => service, (files, touched) => { for (const [f, t] of files) { overlay.set(f, t); bump(f) } touched.forEach(bump) })
|
|
35
|
+
out.allDiagnostics = () => parsed.fileNames.flatMap(f => out.diagnostics(f).map(d => ({ ...d, file: f })))
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class Session {
|
|
40
|
+
constructor(ts, root, options = {}) {
|
|
41
|
+
this.ts = ts
|
|
42
|
+
this.root = root
|
|
43
|
+
this.config = options.config ?? join(root, 'tsconfig.json')
|
|
44
|
+
const raw = ts.readConfigFile(this.config, ts.sys.readFile)
|
|
45
|
+
this.parsed = ts.parseJsonConfigFileContent(raw.config ?? {}, ts.sys, root)
|
|
46
|
+
this.engine = new Engine({ ts, read: f => (existsSync(f) ? readFileSync(f, 'utf8') : undefined), routes: options.routes })
|
|
47
|
+
this.host = null
|
|
48
|
+
this.overlay = new Map()
|
|
49
|
+
this.errors = new Map()
|
|
50
|
+
for (const f of this.parsed.fileNames) if (f.endsWith('.tsx')) this.engine.set(f)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async start() {
|
|
54
|
+
if (this.host) return this.host
|
|
55
|
+
const dir = projectTypescript(this.root)
|
|
56
|
+
this.host = dir ? await tsgoHost({ root: this.root, config: this.config, typescriptDir: dir, overlay: this.overlay }) : memoryHost(this.ts, this.root, this.config, this.parsed, this.overlay)
|
|
57
|
+
return this.host
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Saved files: the checker reads them again, except where a shadow still stands in for them. */
|
|
61
|
+
touch(files) {
|
|
62
|
+
const plain = files.filter(f => !this.engine.maps.has(f))
|
|
63
|
+
for (const f of plain) this.overlay.delete(f)
|
|
64
|
+
this.host.update(new Map(), plain)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Findings and the checker's errors in the files inference touches, in source positions. */
|
|
68
|
+
async analyze(changed) {
|
|
69
|
+
if (!this.engine.needed()) return { findings: new Map(), errors: new Map(), started: false }
|
|
70
|
+
const fresh = !this.host
|
|
71
|
+
const host = await this.start()
|
|
72
|
+
const findings = this.engine.run(host, fresh ? undefined : changed)
|
|
73
|
+
const redo = fresh || !changed ? this.engine.affected() : this.engine.touched
|
|
74
|
+
for (const file of redo) {
|
|
75
|
+
if (!this.engine.facts.has(file)) { this.errors.delete(file); continue }
|
|
76
|
+
const list = this.engine.errors(host, file)
|
|
77
|
+
if (list.length) this.errors.set(file, list)
|
|
78
|
+
else this.errors.delete(file)
|
|
79
|
+
}
|
|
80
|
+
return { findings, errors: this.errors, started: true }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every file's errors, the way `tsc --noEmit` would report them with inference applied. */
|
|
84
|
+
async all() {
|
|
85
|
+
const host = await this.start()
|
|
86
|
+
const findings = this.engine.needed() ? this.engine.run(host) : new Map()
|
|
87
|
+
const diags = host.allDiagnostics()
|
|
88
|
+
const errors = new Map()
|
|
89
|
+
const files = new Set(diags.map(d => d.file))
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const real = [...this.engine.facts.keys()].find(f => f.toLowerCase() === file.toLowerCase()) ?? file
|
|
92
|
+
if (!this.engine.facts.has(real)) {
|
|
93
|
+
errors.set(real, diags.filter(d => d.file === file).map(d => ({ code: `TS${d.code}`, start: d.start, end: d.start + d.length, why: d.text, help: 'Fix the type error' })))
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const list = this.engine.errors(host, real)
|
|
97
|
+
if (list.length) errors.set(real, list)
|
|
98
|
+
}
|
|
99
|
+
return { findings, errors }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close() { this.host?.close(); this.host = null }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { Session, projectTypescript }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A shadow is the source with a few spans replaced (annotations in, `bind:` out). Nothing else moves, so mapping a
|
|
2
|
+
// position is a binary search over the edits, in both directions, and it never needs a source map. Research 44 §5.
|
|
3
|
+
|
|
4
|
+
/** @param {string} text @param {{ start: number, end: number, text: string }[]} edits sorted, not overlapping */
|
|
5
|
+
function shadow(text, edits) {
|
|
6
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start)
|
|
7
|
+
const parts = []
|
|
8
|
+
const segments = [] // { src, out, len }: runs that are unchanged
|
|
9
|
+
let at = 0, out = 0
|
|
10
|
+
for (const e of sorted) {
|
|
11
|
+
const len = e.start - at
|
|
12
|
+
segments.push({ src: at, out, len })
|
|
13
|
+
parts.push(text.slice(at, e.start), e.text)
|
|
14
|
+
out += len + e.text.length
|
|
15
|
+
at = e.end
|
|
16
|
+
}
|
|
17
|
+
segments.push({ src: at, out, len: text.length - at })
|
|
18
|
+
parts.push(text.slice(at))
|
|
19
|
+
return { text: parts.join(''), segments, edits: sorted }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function find(segments, pos, key) {
|
|
23
|
+
let lo = 0, hi = segments.length - 1
|
|
24
|
+
while (lo < hi) {
|
|
25
|
+
const mid = (lo + hi + 1) >> 1
|
|
26
|
+
if (segments[mid][key] <= pos) lo = mid
|
|
27
|
+
else hi = mid - 1
|
|
28
|
+
}
|
|
29
|
+
return segments[lo]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Source position to shadow position. */
|
|
33
|
+
const toShadow = (map, pos) => { const s = find(map.segments, pos, 'src'); return s.out + Math.min(pos - s.src, s.len) }
|
|
34
|
+
|
|
35
|
+
/** Shadow position to source position. A position inside generated text lands where that text was inserted. */
|
|
36
|
+
const toSource = (map, pos) => { const s = find(map.segments, pos, 'out'); return s.src + Math.min(pos - s.out, s.len) }
|
|
37
|
+
|
|
38
|
+
/** Whether a shadow span is inside text the source doesn't have. */
|
|
39
|
+
const inGenerated = (map, start, length) => {
|
|
40
|
+
const s = find(map.segments, start, 'out')
|
|
41
|
+
return start >= s.out + s.len && length >= 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const identity = text => ({ text, segments: [{ src: 0, out: 0, len: text.length }], edits: [] })
|
|
45
|
+
|
|
46
|
+
module.exports = { shadow, toShadow, toSource, inGenerated, identity }
|