@preventive/compare 1.0.2 → 1.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/compare.d.ts +45 -1
- package/compare.js +5 -0
- package/names.d.ts +3 -1
- package/package.json +9 -2
- package/src/changes.js +46 -3
- package/src/erasure.js +109 -0
- package/src/names/analyze.js +27 -2
- package/src/names/pairing.js +153 -39
- package/src/names/rename.js +4 -1
- package/src/oxc.js +1 -0
- package/src/oxc.node.js +3 -1
- package/src/pretty.js +37 -4
- package/src/types.js +8 -0
- package/src/types.node.js +114 -0
package/compare.d.ts
CHANGED
|
@@ -34,12 +34,23 @@ export type Status = 'added' | 'deleted' | 'modified' | 'renamed' | 'type-change
|
|
|
34
34
|
// file and `new` for a deleted one; a renamed file has both, under their
|
|
35
35
|
// two paths. `similarity` is a rename's, as git's similarity index: the
|
|
36
36
|
// percentage of the new file the old one accounts for, 100 where the content
|
|
37
|
-
// is unchanged; null for anything else.
|
|
37
|
+
// is unchanged; null for anything else. `typeOnly`, on Node alone, stamps a
|
|
38
|
+
// change whose every side there is a file named as a declaration file
|
|
39
|
+
// (`.d.ts`, `.d.mts`, `.d.cts`, `.d.css.ts`) and holds types alone, as
|
|
40
|
+
// proveTypeOnly proves: one that runs nothing on either side, as a side not
|
|
41
|
+
// there runs nothing. A retained `export {}` runs nothing as a module but emits
|
|
42
|
+
// a marker transpiled to CommonJS, so it is allowed only on both sides at once
|
|
43
|
+
// or neither, never added or removed alone, and only where the two extensions
|
|
44
|
+
// fix the same module format (`.mts` a module, `.cts` CommonJS) -- a rename from
|
|
45
|
+
// a `.d.mts` to a `.d.cts` runs it, then throws on it, and a `.d.ts`, its format
|
|
46
|
+
// its package's to say and not known here, never cancels one. Absent off Node,
|
|
47
|
+
// and without oxc-parser.
|
|
38
48
|
export interface FileStatus {
|
|
39
49
|
status: Status
|
|
40
50
|
old: TreeFile | null
|
|
41
51
|
new: TreeFile | null
|
|
42
52
|
similarity: number | null
|
|
53
|
+
typeOnly?: true
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
// A status with what happened to the content: `blocks`, @preventive/diff's
|
|
@@ -176,6 +187,39 @@ export function layoutPackageDirs(baseDirs: Map<string, string> | null, otherDir
|
|
|
176
187
|
// (`./dir/`, `./src/dir/`); a prefix the root doesn't lead is dropped.
|
|
177
188
|
export function rootLabel(root: Root | null, prefix: string): { prefix: string; from: string | null; to: string | null }
|
|
178
189
|
|
|
190
|
+
// Why a text is not proven to hold types alone: proving needs Node, whose type
|
|
191
|
+
// stripping is held to oxc's tree, and oxc-parser, an optional peer, there,
|
|
192
|
+
// and is not asked of a stripping that once failed as may leave it unsound
|
|
193
|
+
// (`unsupported`); Node's stripping or oxc finds an error in it, as in
|
|
194
|
+
// `export const x: number`, which only a declaration file may write, or it
|
|
195
|
+
// nests too deep for either (`unparsed`); a statement stripping leaves loads a
|
|
196
|
+
// module, as `import { type A }` and `export *` from one do (`imports`), or is
|
|
197
|
+
// anything else (`code`); or Node's stripping, or a reading of what it leaves,
|
|
198
|
+
// does not find what oxc does (`disagree`).
|
|
199
|
+
export type TypeOnlyReason = 'unsupported' | 'unparsed' | 'imports' | 'code' | 'disagree'
|
|
200
|
+
|
|
201
|
+
// Whether `text`, read as TypeScript, holds types alone, so that Node, which
|
|
202
|
+
// reads a `.d.ts` as the `.ts` it is named and runs what its type stripping
|
|
203
|
+
// leaves, runs nothing of it:
|
|
204
|
+
// - oxc's tree has no statement but those stripping erases whole -- interfaces,
|
|
205
|
+
// type aliases, anything `declare`d (bodies and initializers with it), a
|
|
206
|
+
// function's signature, `import type` and `export type`, a namespace of types
|
|
207
|
+
// alone -- and a bare `export {}`. `import { type A }` loads its module like
|
|
208
|
+
// any import, inline `type` or not, so it is not type-only; nor is
|
|
209
|
+
// `export { type A }`, which is only `export {}` once stripping takes its
|
|
210
|
+
// specifiers out; nor `export as namespace N`, left as invalid JavaScript that
|
|
211
|
+
// throws when imported;
|
|
212
|
+
// - Node's stripTypeScriptTypes erases, to the character, what oxc finds erased
|
|
213
|
+
// and leaves nothing it does not, but comments, the hashbang and that one;
|
|
214
|
+
// - what it leaves, read token by token apart from either, holds nothing else;
|
|
215
|
+
// - nor does what its transform mode (--experimental-transform-types) leaves.
|
|
216
|
+
// `inert` is true where a bare `export {}` is kept: valid JavaScript that loads
|
|
217
|
+
// and runs nothing as a module, but a marker transpiled to CommonJS, so a change
|
|
218
|
+
// is types only only where both sides match in it. `at` is where in `text` what
|
|
219
|
+
// is found begins, where it has a place. Throws a TypeError where `text` is not a
|
|
220
|
+
// well-formed string. Node warns once that stripTypeScriptTypes is experimental.
|
|
221
|
+
export function proveTypeOnly(text: string): { proven: true; inert: boolean } | { proven: false; reason: TypeOnlyReason; at: number | null }
|
|
222
|
+
|
|
179
223
|
// `path` is the name at fault as its entry spelled it, and `tree` which tree
|
|
180
224
|
// holds it.
|
|
181
225
|
export class CompareError extends Error {
|
package/compare.js
CHANGED
|
@@ -25,5 +25,10 @@ export { renameParts } from './src/paths.js'
|
|
|
25
25
|
// each old path, directory and package directory as the new tree lays it out.
|
|
26
26
|
export { layoutDir, layoutPackageDirs, rootLabel, treeLayout } from './src/layout.js'
|
|
27
27
|
|
|
28
|
+
// Whether a TypeScript text holds types alone, so that Node runs nothing of it,
|
|
29
|
+
// as compareTrees stamps a declaration file's change: proven on Node alone, where
|
|
30
|
+
// oxc-parser, an optional peer, is loaded on first use.
|
|
31
|
+
export { proveTypeOnly } from '#types'
|
|
32
|
+
|
|
28
33
|
// What a tree that does not hold together is refused with.
|
|
29
34
|
export { CompareError } from './src/error.js'
|
package/names.d.ts
CHANGED
|
@@ -28,7 +28,9 @@ export type RenameReason = 'unparsed' | 'dynamic' | 'jsx' | 'block-function' | '
|
|
|
28
28
|
// it; and the blocks checked to take `before` to the new side (thrown should
|
|
29
29
|
// they not). `reason` says why no names were given; `reads` the properties
|
|
30
30
|
// either text reads names through (`name`, `toString`, `stack`, `callee`,
|
|
31
|
-
// `caller`), sorted, as code reading its names may act otherwise renamed
|
|
31
|
+
// `caller`), sorted, as code reading its names may act otherwise renamed:
|
|
32
|
+
// none where none is renamed, and `name` only where a name renamed is a
|
|
33
|
+
// function's or class's, the one name it reads (`function f`, `f = () => {}`);
|
|
32
34
|
// `marks` the plain diff's blocks that differ in renamed names alone; and
|
|
33
35
|
// `warnings` what a check that should never fail found.
|
|
34
36
|
export function renamedDiff(before: string, after: string, options?: RenameOptions & { whitespace?: 'none' | 'all' | 'change' | 'trailing' }): Promise<{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/compare",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Git-compatible file tree diffs and minified JavaScript comparison",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./compare.js",
|
|
7
7
|
"types": "./compare.d.ts",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"#oxc": {
|
|
32
32
|
"node": "./src/oxc.node.js",
|
|
33
33
|
"default": "./src/oxc.js"
|
|
34
|
+
},
|
|
35
|
+
"#types": {
|
|
36
|
+
"node": "./src/types.node.js",
|
|
37
|
+
"default": "./src/types.js"
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"files": [
|
|
@@ -45,6 +49,7 @@
|
|
|
45
49
|
"src/blocks.js",
|
|
46
50
|
"src/changes.js",
|
|
47
51
|
"src/css.js",
|
|
52
|
+
"src/erasure.js",
|
|
48
53
|
"src/error.js",
|
|
49
54
|
"src/layout.js",
|
|
50
55
|
"src/marked.js",
|
|
@@ -71,6 +76,8 @@
|
|
|
71
76
|
"src/similarity.js",
|
|
72
77
|
"src/text.js",
|
|
73
78
|
"src/tree.js",
|
|
79
|
+
"src/types.js",
|
|
80
|
+
"src/types.node.js",
|
|
74
81
|
"src/whitespace.js",
|
|
75
82
|
"src/words.js"
|
|
76
83
|
],
|
package/src/changes.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// compareTrees: a path in both trees is modified or type-changed, a path in one
|
|
2
2
|
// added or deleted unless renames.js pairs it with another, as git diff -M does
|
|
3
|
-
// and, unless `renamesByPath` is false, by the paths a build moves files to
|
|
3
|
+
// and, unless `renamesByPath` is false, by the paths a build moves files to; on
|
|
4
|
+
// Node, a change to a declaration file holding types alone on each side stamped so.
|
|
4
5
|
|
|
6
|
+
import { proveTypeOnly } from '#types'
|
|
5
7
|
import { MAX_SCORE } from './similarity.js'
|
|
6
8
|
import { findRenames } from './renames.js'
|
|
7
|
-
import { checkLineOptions, contentOf } from './text.js'
|
|
9
|
+
import { checkLineOptions, contentOf, textOf } from './text.js'
|
|
8
10
|
import { readTree, sameContent } from './tree.js'
|
|
9
11
|
|
|
10
12
|
// git's defaults: -M at 50%, diff.renameLimit at 1000.
|
|
@@ -42,6 +44,47 @@ const sameFile = (a, b) => sameContent(a, b) && (a.type !== 'file' || a.mode ===
|
|
|
42
44
|
|
|
43
45
|
const status = (kind, old, fresh, similarity = null) => ({ status: kind, old, new: fresh, similarity })
|
|
44
46
|
|
|
47
|
+
// A declaration file as TypeScript names one: `.d.ts`, `.d.mts`, `.d.cts`, and
|
|
48
|
+
// `.d.css.ts` for a `.css` file's.
|
|
49
|
+
const DECLARATION = /\.d\.(?:[^./]+\.)?[cm]?ts$/u
|
|
50
|
+
|
|
51
|
+
// The module system a declaration file's extension fixes, or null where its package says and this cannot:
|
|
52
|
+
// `.mts` an ES module, `.cts` a CommonJS one, a `.ts` either. `export {}` runs nothing as a module and
|
|
53
|
+
// throws as CommonJS, so where the format is null, or the two sides' differ, it does not cancel.
|
|
54
|
+
const fixedModule = (path) => (path.endsWith('.mts') ? 'module' : path.endsWith('.cts') ? 'commonjs' : null)
|
|
55
|
+
|
|
56
|
+
// A file's proof: that it holds types alone, and whether that rests on a retained `export {}` (`inert`),
|
|
57
|
+
// which runs nothing as a module but emits a marker transpiled to CommonJS. A side not there holds
|
|
58
|
+
// nothing. Not proven where the proof throws, as no check should.
|
|
59
|
+
function proven(file) {
|
|
60
|
+
if (file === null) return { proven: true, inert: false }
|
|
61
|
+
const text = textOf(file)
|
|
62
|
+
try {
|
|
63
|
+
return text === undefined ? { proven: false } : proveTypeOnly(text)
|
|
64
|
+
} catch {
|
|
65
|
+
return { proven: false }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Stamped where each side there is a file named as a declaration file and proven to hold types alone, and
|
|
70
|
+
// a retained `export {}` is on both sides or neither: on one alone it is a change in what runs once
|
|
71
|
+
// transpiled, so the change is not types only. For it to cancel, the two extensions must fix the same
|
|
72
|
+
// module format: a rename from a `.d.mts` to a `.d.cts` runs it, then throws on it, and a `.d.ts` leaves
|
|
73
|
+
// the format to its package, which this cannot read, so a retained `export {}` in one never cancels.
|
|
74
|
+
function stamped(change) {
|
|
75
|
+
const sides = [change.old, change.new].filter((file) => file !== null)
|
|
76
|
+
if (!sides.every((file) => file.type === 'file' && DECLARATION.test(file.path))) return change
|
|
77
|
+
// A mode changed, an executable bit set or cleared, is a change in its own right, types aside.
|
|
78
|
+
if (change.old && change.new && change.old.mode !== change.new.mode) return change
|
|
79
|
+
const fresh = proven(change.new), old = proven(change.old)
|
|
80
|
+
if (!old.proven || !fresh.proven || Boolean(old.inert) !== Boolean(fresh.inert)) return change
|
|
81
|
+
if (old.inert) {
|
|
82
|
+
const format = fixedModule(change.old.path)
|
|
83
|
+
if (format === null || format !== fixedModule(change.new.path)) return change
|
|
84
|
+
}
|
|
85
|
+
return { ...change, typeOnly: true }
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
function pathChanges(old, fresh) {
|
|
46
89
|
const added = [], changes = [], deleted = []
|
|
47
90
|
for (const [path, file] of old) {
|
|
@@ -86,5 +129,5 @@ export function compareTrees(a, b, { renames = true, renamesByPath = true, renam
|
|
|
86
129
|
for (const [s, file] of deleted.entries()) if (!moved.has(s)) changes.push(status('deleted', file, null))
|
|
87
130
|
// A rename stands at its new path, as git lists it.
|
|
88
131
|
changes.sort((x, y) => byPath((x.new ?? x.old).path, (y.new ?? y.old).path))
|
|
89
|
-
return lines ? changes.map((change) => ({ ...change, ...contentOf(change.old, change.new, lineOptions) })) : changes
|
|
132
|
+
return (lines ? changes.map((change) => ({ ...change, ...contentOf(change.old, change.new, lineOptions) })) : changes).map(stamped)
|
|
90
133
|
}
|
package/src/erasure.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// What Node's type stripping erases of TypeScript, as oxc's syntax tree has it, and what a text it
|
|
2
|
+
// stripped may hold: Node reads a `.d.ts` as the `.ts` it is named and runs what it does not erase,
|
|
3
|
+
// so a declaration file runs nothing only where stripping leaves nothing to run. Nothing here
|
|
4
|
+
// parses; types.node.js gives both the tree and Node's text.
|
|
5
|
+
|
|
6
|
+
// What stripping makes of a statement: nothing (`erased`); a bare `export {}` the source writes, left as
|
|
7
|
+
// valid JavaScript that loads and runs nothing (`inert`); a statement that loads a module (`imports`); or
|
|
8
|
+
// code -- `export as namespace N`, left verbatim as invalid JavaScript that throws when the file is
|
|
9
|
+
// imported, among it.
|
|
10
|
+
export const CODE = 'code', ERASED = 'erased', IMPORT = 'imports', INERT = 'inert'
|
|
11
|
+
|
|
12
|
+
const TYPES = new Set(['TSInterfaceDeclaration', 'TSTypeAliasDeclaration'])
|
|
13
|
+
|
|
14
|
+
// A namespace not declared is erased where TypeScript emits nothing for it: it holds interfaces,
|
|
15
|
+
// type aliases and such namespaces alone, exported or not. One declared within it is held to the
|
|
16
|
+
// same, as Node holds it: `namespace N { declare namespace M { const x = 1 } }` is refused.
|
|
17
|
+
function typesAlone(namespace) {
|
|
18
|
+
if (namespace.kind !== 'namespace') return false
|
|
19
|
+
if (namespace.body?.type === 'TSModuleDeclaration') return typesAlone(namespace.body)
|
|
20
|
+
if (namespace.body?.type !== 'TSModuleBlock') return false
|
|
21
|
+
return namespace.body.body.every((statement) => {
|
|
22
|
+
const node = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement
|
|
23
|
+
return TYPES.has(node?.type) || (node?.type === 'TSModuleDeclaration' && typesAlone(node))
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A declaration erased whole: a type, an interface, a function's signature, anything `declare`d
|
|
28
|
+
// (its initializers and bodies with it), a namespace of types. A decorator is code, kept where
|
|
29
|
+
// the class it decorates is erased (`@d declare class A {}` leaves `@d`).
|
|
30
|
+
function erased(node) {
|
|
31
|
+
switch (node.type) {
|
|
32
|
+
case 'TSInterfaceDeclaration': case 'TSTypeAliasDeclaration': case 'TSDeclareFunction': return true
|
|
33
|
+
case 'VariableDeclaration': case 'TSEnumDeclaration': return node.declare === true
|
|
34
|
+
case 'ClassDeclaration': return node.declare === true && !(node.decorators?.length > 0)
|
|
35
|
+
case 'TSModuleDeclaration': return node.declare === true || typesAlone(node)
|
|
36
|
+
default: return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A statement of a program's, as stripping leaves it. `import type` and `export type` are erased whole,
|
|
41
|
+
// loading nothing; `import { type A }` from a module loads it all the same, inline `type` or not, so it
|
|
42
|
+
// is a module load. A bare `export {}` is inert; `export { type A }` is code, not inert: it is `export {}`
|
|
43
|
+
// only once stripping takes its specifiers out, not as the source has it, and that a stripped no-op would
|
|
44
|
+
// run nothing is no reason to call the source type-only.
|
|
45
|
+
export function strippedAs(statement) {
|
|
46
|
+
switch (statement.type) {
|
|
47
|
+
case 'ImportDeclaration': return statement.importKind === 'type' ? ERASED : IMPORT
|
|
48
|
+
case 'ExportAllDeclaration': return statement.exportKind === 'type' ? ERASED : IMPORT
|
|
49
|
+
case 'TSImportEqualsDeclaration':
|
|
50
|
+
if (statement.importKind === 'type') return ERASED
|
|
51
|
+
return statement.moduleReference.type === 'TSExternalModuleReference' ? IMPORT : CODE
|
|
52
|
+
case 'ExportNamedDeclaration':
|
|
53
|
+
if (statement.declaration) return erased(statement.declaration) ? ERASED : strippedAs(statement.declaration)
|
|
54
|
+
if (statement.exportKind === 'type') return ERASED
|
|
55
|
+
if (statement.source) return IMPORT
|
|
56
|
+
return statement.specifiers.length === 0 ? INERT : CODE
|
|
57
|
+
case 'ExportDefaultDeclaration': return statement.declaration.type === 'TSInterfaceDeclaration' || statement.declaration.type === 'TSDeclareFunction' ? ERASED : CODE
|
|
58
|
+
// `export as namespace N` is left verbatim, invalid as JavaScript, so importing the file throws.
|
|
59
|
+
case 'TSNamespaceExportDeclaration': return CODE
|
|
60
|
+
default: return erased(statement) ? ERASED : CODE
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What a stripped text may hold, read token by token as no parser reads it: blanks and line breaks
|
|
65
|
+
// (JavaScript's, `\s`), comments, a hashbang first, and `export {}` (empty, or what was all type
|
|
66
|
+
// specifiers), with any trailing `;`.
|
|
67
|
+
const LINE_END = /[\n\r\u2028\u2029]/gu
|
|
68
|
+
const BLANK = /\s/u
|
|
69
|
+
const EXPORT_EMPTY = [/export(?![\w$])/uy, /\{/uy, /\}/uy]
|
|
70
|
+
|
|
71
|
+
function lineEnd(text, at) {
|
|
72
|
+
LINE_END.lastIndex = at
|
|
73
|
+
return LINE_END.test(text) ? LINE_END.lastIndex - 1 : text.length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Past blanks and comments; an unclosed comment stays, for what follows to refuse.
|
|
77
|
+
function skip(text, at) {
|
|
78
|
+
for (;;) {
|
|
79
|
+
const close = text.startsWith('/*', at) ? text.indexOf('*/', at + 2) : -1
|
|
80
|
+
if (BLANK.test(text[at] ?? '')) at++
|
|
81
|
+
else if (text.startsWith('//', at)) at = lineEnd(text, at)
|
|
82
|
+
else if (close === -1) return at
|
|
83
|
+
else at = close + 2
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Past an `export {}` from `from`, with any trailing `;`, or -1 where none starts there.
|
|
88
|
+
function exportEmptyEnd(text, from) {
|
|
89
|
+
let at = from
|
|
90
|
+
for (const token of EXPORT_EMPTY) {
|
|
91
|
+
token.lastIndex = skip(text, at)
|
|
92
|
+
at = token.test(text) ? token.lastIndex : -1
|
|
93
|
+
if (at === -1) return -1
|
|
94
|
+
}
|
|
95
|
+
const semicolon = skip(text, at)
|
|
96
|
+
return text[semicolon] === ';' ? semicolon + 1 : at
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Where a stripped text first holds anything else, or -1 where it holds nothing else.
|
|
100
|
+
export function leftover(text) {
|
|
101
|
+
let at = text.startsWith('#!') ? lineEnd(text, 0) : 0
|
|
102
|
+
for (;;) {
|
|
103
|
+
at = skip(text, at)
|
|
104
|
+
if (at === text.length) return -1
|
|
105
|
+
const end = exportEmptyEnd(text, at)
|
|
106
|
+
if (end === -1) return at
|
|
107
|
+
at = end
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/names/analyze.js
CHANGED
|
@@ -10,6 +10,9 @@ const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFu
|
|
|
10
10
|
const BLOCKS = new Set(['BlockStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement'])
|
|
11
11
|
// Globals that make a script a CommonJS module, its top level its own.
|
|
12
12
|
const COMMONJS = new Set(['exports', 'module', 'require'])
|
|
13
|
+
// A function or class with no name of its own, which takes the one it is bound to.
|
|
14
|
+
const ANONYMOUS = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'])
|
|
15
|
+
const NAMING_ASSIGNMENTS = new Set(['=', '&&=', '||=', '??='])
|
|
13
16
|
|
|
14
17
|
// oxc is loaded outside the try: one missing is no text that won't parse. A text
|
|
15
18
|
// oxc's semantic check finds an error in is one it can't vouch for.
|
|
@@ -90,6 +93,24 @@ function moduleNames(node, s) {
|
|
|
90
93
|
else if (node.declaration?.id) s.keeping.add(node.declaration.id)
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
function anonymous(node) {
|
|
97
|
+
while (node?.type === 'ParenthesizedExpression') node = node.expression
|
|
98
|
+
return ANONYMOUS.has(node?.type) && !node.id
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The names functions and classes are named by, as `.name` reads them: a
|
|
102
|
+
// declaration's or an expression's own, or the one an unnamed one is bound to
|
|
103
|
+
// (`var f = () => {}`, `f = function () {}`, `(f = class {}) => f`).
|
|
104
|
+
function namingNames(node, s) {
|
|
105
|
+
switch (node.type) {
|
|
106
|
+
case 'FunctionDeclaration': case 'FunctionExpression': case 'ClassDeclaration': case 'ClassExpression': if (node.id) s.naming.add(node.id); break
|
|
107
|
+
case 'VariableDeclarator': if (node.id.type === 'Identifier' && anonymous(node.init)) s.naming.add(node.id); break
|
|
108
|
+
case 'AssignmentPattern': if (node.left.type === 'Identifier' && anonymous(node.right)) s.naming.add(node.left); break
|
|
109
|
+
case 'AssignmentExpression': if (NAMING_ASSIGNMENTS.has(node.operator) && node.left.type === 'Identifier' && anonymous(node.right)) s.naming.add(node.left); break
|
|
110
|
+
default: break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
// Names that are no variable's (keys, labels), names kept, and the properties
|
|
94
115
|
// names are read through.
|
|
95
116
|
function otherNames(node, s) {
|
|
@@ -181,7 +202,7 @@ function enter(node, where, s) {
|
|
|
181
202
|
|
|
182
203
|
// A work list rather than a recursion: a minified file's expressions nest deeply.
|
|
183
204
|
function walk(program) {
|
|
184
|
-
const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), reads: new Set(), references: [], work: [] }
|
|
205
|
+
const s = { declarations: new Map(), scopes: [], variables: [], keeping: new Set(), others: new Set(), naming: new Set(), reads: new Set(), references: [], work: [] }
|
|
185
206
|
s.root = Object.assign(scope(s, null, true), { strict: program.sourceType === 'module' || saysStrict(program.body) })
|
|
186
207
|
s.work.push([program, s.root])
|
|
187
208
|
while (s.work.length > 0) {
|
|
@@ -190,6 +211,7 @@ function walk(program) {
|
|
|
190
211
|
if (!s.declarations.has(node) && !s.others.has(node)) s.references.push([node, where])
|
|
191
212
|
continue
|
|
192
213
|
}
|
|
214
|
+
namingNames(node, s)
|
|
193
215
|
const reason = visit(node, where, s)
|
|
194
216
|
if (reason) return { reason }
|
|
195
217
|
enter(node, where, s)
|
|
@@ -207,7 +229,10 @@ function resolved(program, s) {
|
|
|
207
229
|
if (variable) occurrences.push([node, variable, where])
|
|
208
230
|
else globals.push([node, where])
|
|
209
231
|
}
|
|
210
|
-
for (const [node, variable] of occurrences)
|
|
232
|
+
for (const [node, variable] of occurrences) {
|
|
233
|
+
if (s.keeping.has(node)) variable.kept = true
|
|
234
|
+
if (s.naming.has(node)) variable.naming = true
|
|
235
|
+
}
|
|
211
236
|
const own = program.sourceType === 'module' || program.sourceType === 'commonjs' || globals.some(([node]) => COMMONJS.has(node.name))
|
|
212
237
|
for (const variable of s.variables) variable.aside = variable.name.length <= RENAMED_MAX_LENGTH && MINIFIED.test(variable.name) && !variable.kept && (own || variable.scope !== s.root)
|
|
213
238
|
// A shorthand property's two names are one, at one place.
|
package/src/names/pairing.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// each other, and two each the other's most counted pair, a tie pairing neither.
|
|
4
4
|
|
|
5
5
|
import { changeSet } from '@preventive/diff'
|
|
6
|
+
import { RESERVED } from './syntax.js'
|
|
6
7
|
|
|
7
8
|
// Unchanged lines between changes that pair as unchanged ones, not as changed.
|
|
8
9
|
const ANCHORED_RUN = 3
|
|
@@ -10,21 +11,125 @@ const ANCHORED_RUN = 3
|
|
|
10
11
|
// Whitespace alone never tells a name apart.
|
|
11
12
|
const changes = (a, b) => changeSet(a, b, { horizon: 0, whitespace: 'all' })
|
|
12
13
|
|
|
13
|
-
// A text's lines with its renamable variables' names set aside (`key`),
|
|
14
|
-
// each line's
|
|
15
|
-
|
|
14
|
+
// A text's lines with its renamable variables' names set aside (`key`), but
|
|
15
|
+
// those `written` names (a pair found), each line's variables set aside in
|
|
16
|
+
// order (`lines`), and where in its key each stood (`places`).
|
|
17
|
+
function keyed(text, { occurrences }, written = () => null) {
|
|
16
18
|
const starts = [0]
|
|
17
19
|
for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) starts.push(at + 1)
|
|
18
|
-
const lines = starts.map(() => [])
|
|
19
|
-
let at = 0, key = '', line = 0
|
|
20
|
+
const lines = starts.map(() => []), places = starts.map(() => [])
|
|
21
|
+
let at = 0, key = '', line = 0, removed = 0
|
|
20
22
|
for (const [node, variable] of occurrences) {
|
|
21
23
|
if (!variable.aside) continue
|
|
22
|
-
|
|
24
|
+
const name = written(variable)
|
|
25
|
+
for (; line + 1 < starts.length && starts[line + 1] <= node.start; line++) removed = 0
|
|
26
|
+
if (name === null) {
|
|
27
|
+
lines[line].push(variable)
|
|
28
|
+
places[line].push(node.start - starts[line] - removed)
|
|
29
|
+
}
|
|
30
|
+
key += text.slice(at, node.start) + (name ?? '')
|
|
31
|
+
removed += node.end - node.start - (name?.length ?? 0)
|
|
23
32
|
at = node.end
|
|
24
|
-
while (line + 1 < starts.length && starts[line + 1] <= node.start) line++
|
|
25
|
-
lines[line].push(variable)
|
|
26
33
|
}
|
|
27
|
-
|
|
34
|
+
key += text.slice(at)
|
|
35
|
+
return { key, keys: key.split('\n'), lines, places }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Lines as a diff that sets whitespace aside compares them.
|
|
39
|
+
const SPACE = /[ \t\n\v\f\r]/gu
|
|
40
|
+
const plain = (lines) => lines.map((line) => line.replace(SPACE, ''))
|
|
41
|
+
|
|
42
|
+
// The kept lines of each side a run of changes could stand in place of: a run
|
|
43
|
+
// bordered by lines alike its own may sit anywhere along them short of the next
|
|
44
|
+
// run, as diff slides it, so which of those it leaves kept, facing which, is
|
|
45
|
+
// the diff's pick and says nothing (a function added before one written alike
|
|
46
|
+
// with names set aside: either's header may face the old one's) -- unless the
|
|
47
|
+
// line it would keep instead reads the same variables (`same`), which say
|
|
48
|
+
// the same either way.
|
|
49
|
+
function slack(a, b, blocks, sameA, sameB) {
|
|
50
|
+
const loose = (lines, runs, same) => {
|
|
51
|
+
const flags = new Uint8Array(lines.length)
|
|
52
|
+
runs.forEach(([start, end], k) => {
|
|
53
|
+
const ceiling = runs[k + 1]?.[0] ?? lines.length, floor = k > 0 ? runs[k - 1][1] : 0
|
|
54
|
+
for (let e = end, s = start; s > floor && lines[s - 1] === lines[e - 1]; s--, e--) if (!same(s - 1, e - 1)) flags[s - 1] = 1
|
|
55
|
+
for (let e = end, s = start; e < ceiling && lines[s] === lines[e]; s++, e++) if (!same(e, s)) flags[e] = 1
|
|
56
|
+
})
|
|
57
|
+
return flags
|
|
58
|
+
}
|
|
59
|
+
return [loose(a, blocks.filter(({ a0, a1 }) => a1 > a0).map(({ a0, a1 }) => [a0, a1]), sameA),
|
|
60
|
+
loose(b, blocks.filter(({ b0, b1 }) => b1 > b0).map(({ b0, b1 }) => [b0, b1]), sameB)]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Whether two of a run's lines, from `from`, read the same variables in order.
|
|
64
|
+
const reading = (k, from = 0) => (x, y) => {
|
|
65
|
+
const one = k.lines[from + x], other = k.lines[from + y]
|
|
66
|
+
return one.length === other.length && one.every((variable, n) => variable === other[n])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The pairs of lines a diff of `a` and `b` keeps, each told whether slack.
|
|
70
|
+
function* kept(a, b, sameA, sameB) {
|
|
71
|
+
const text = (lines) => lines.map((line) => `${line}\n`).join('')
|
|
72
|
+
const blocks = changes(text(a), text(b))
|
|
73
|
+
const [looseA, looseB] = slack(a, b, blocks, sameA, sameB)
|
|
74
|
+
let i = 0, j = 0
|
|
75
|
+
for (const { a0, a1, b1 } of [...blocks, { a0: a.length, a1: a.length, b1: b.length }]) {
|
|
76
|
+
for (; i < a0; i++, j++) yield [i, j, looseA[i] || looseB[j]]
|
|
77
|
+
i = a1
|
|
78
|
+
j = b1
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A run of lines' tokens (`list`) and the variable each stands for (`at`): the
|
|
83
|
+
// place a variable stood one token alike any other's. Null past MAX_TOKENS.
|
|
84
|
+
const PLACE = '*', TOKEN = /[\p{ID_Continue}$\u200C\u200D]+|\S/gu
|
|
85
|
+
// A diff's cost grows faster than its length: a change of more tokens a side
|
|
86
|
+
// than this pairs by its lines alone.
|
|
87
|
+
const MAX_TOKENS = 20000
|
|
88
|
+
function tokens(k, from, to) {
|
|
89
|
+
const at = [], list = []
|
|
90
|
+
const add = (token, variable) => {
|
|
91
|
+
list.push(token)
|
|
92
|
+
at.push(variable)
|
|
93
|
+
return list.length <= MAX_TOKENS
|
|
94
|
+
}
|
|
95
|
+
const text = (line, start, end) => {
|
|
96
|
+
for (const [token] of k.keys[line].slice(start, end).matchAll(TOKEN)) if (!add(`=${token}`, null)) return false
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
for (let line = from; line < to; line++) {
|
|
100
|
+
let start = 0
|
|
101
|
+
for (const [n, place] of k.places[line].entries()) {
|
|
102
|
+
if (!text(line, start, place) || !add(PLACE, k.lines[line][n])) return null
|
|
103
|
+
start = place
|
|
104
|
+
}
|
|
105
|
+
if (!text(line, start)) return null
|
|
106
|
+
}
|
|
107
|
+
return { list, at }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A word but a keyword: what ties tokens kept together to their own place, as
|
|
111
|
+
// punctuation and keywords alone line up any statement with any other.
|
|
112
|
+
const WORD = /^=[\p{ID_Start}$_]/u
|
|
113
|
+
const ties = (token) => WORD.test(token) && !RESERVED.has(token.slice(1))
|
|
114
|
+
|
|
115
|
+
// The variables of a change that its tokens, diffed, keep facing each other in
|
|
116
|
+
// a run of tokens kept together holding a word: its lines written anew, wrapped
|
|
117
|
+
// or ended otherwise (`let t = ...;` -> `let t = ...,`), or reading more
|
|
118
|
+
// variables or fewer (`f(a)` -> `f(b, c)`: none, `b` or `c` alike). Lines
|
|
119
|
+
// reordered line up by punctuation alone, `aa(a, b); bb(b);` ->
|
|
120
|
+
// `bb(y, z); aa(x);` facing `(a, b)` with `(y, z)`, which pairs nothing.
|
|
121
|
+
function placed(ka, a0, a1, kb, b0, b1) {
|
|
122
|
+
const reads = (k, from, to) => k.lines.slice(from, to).some((line) => line.length > 0)
|
|
123
|
+
if (!reads(ka, a0, a1) || !reads(kb, b0, b1)) return []
|
|
124
|
+
const a = tokens(ka, a0, a1), b = a && tokens(kb, b0, b1)
|
|
125
|
+
if (!b) return []
|
|
126
|
+
const found = [], pairs = [...kept(a.list, b.list, (x, y) => a.at[x] === a.at[y], (x, y) => b.at[x] === b.at[y])]
|
|
127
|
+
for (let end = 1, start = 0; start < pairs.length; start = end++) {
|
|
128
|
+
while (end < pairs.length && pairs[end][0] === pairs[end - 1][0] + 1 && pairs[end][1] === pairs[end - 1][1] + 1) end++
|
|
129
|
+
const run = pairs.slice(start, end)
|
|
130
|
+
if (run.some(([x]) => ties(a.list[x]))) for (const [x, y, loose] of run) if (a.at[x] && !loose) found.push([a.at[x], b.at[y]])
|
|
131
|
+
}
|
|
132
|
+
return found
|
|
28
133
|
}
|
|
29
134
|
|
|
30
135
|
const tallies = () => ({ backward: new Map(), forward: new Map() })
|
|
@@ -33,40 +138,38 @@ function count(map, from, to) {
|
|
|
33
138
|
map.get(from).set(to, (map.get(from).get(to) ?? 0) + 1)
|
|
34
139
|
}
|
|
35
140
|
|
|
36
|
-
// Pairs of lines counted, a long run of unchanged ones apart from the rest
|
|
37
|
-
//
|
|
38
|
-
//
|
|
141
|
+
// Pairs of lines counted, a long run of unchanged ones apart from the rest, and
|
|
142
|
+
// apart from both the variables a change's tokens pair (`tokened`). Lines
|
|
143
|
+
// reading variables one side has and the other not say nothing. In a change of
|
|
144
|
+
// unlike lengths, lines alike but for a final `;` or `,` pair (a declaration
|
|
145
|
+
// others now follow: `let t = ...;` -> `let t = ...,`).
|
|
39
146
|
function tallied(ka, kb) {
|
|
40
|
-
const changed = tallies(), unchanged = tallies()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (olds.length !== news.length) return
|
|
45
|
-
olds.forEach((variable, k) => {
|
|
46
|
-
count(forward, variable, news[k])
|
|
47
|
-
count(backward, news[k], variable)
|
|
48
|
-
})
|
|
147
|
+
const changed = tallies(), tokened = tallies(), unchanged = tallies()
|
|
148
|
+
const counted = ({ backward, forward }, variable, other) => {
|
|
149
|
+
count(forward, variable, other)
|
|
150
|
+
count(backward, other, variable)
|
|
49
151
|
}
|
|
50
|
-
const
|
|
51
|
-
const trimmed = (lines, from, to) =>
|
|
152
|
+
const alike = (counts, olds, news) => olds.length === news.length && olds.forEach((variable, k) => counted(counts, variable, news[k]))
|
|
153
|
+
const trimmed = (lines, from, to) => plain(lines.slice(from, to).map((line) => line.replace(/[;,]\s*$/u, '')))
|
|
154
|
+
const blocks = changes(ka.key, kb.key)
|
|
155
|
+
const [looseA, looseB] = slack(plain(ka.keys), plain(kb.keys), blocks, reading(ka), reading(kb))
|
|
52
156
|
let i = 0, j = 0
|
|
53
|
-
for (const block of [...
|
|
54
|
-
const
|
|
55
|
-
|
|
157
|
+
for (const block of [...blocks, { a0: ka.lines.length, a1: ka.lines.length, b0: kb.lines.length, b1: kb.lines.length }]) {
|
|
158
|
+
const counts = block.a0 - i >= ANCHORED_RUN ? unchanged : changed
|
|
159
|
+
for (; i < block.a0; i++, j++) if (!looseA[i] && !looseB[j]) alike(counts, ka.lines[i], kb.lines[j])
|
|
56
160
|
const [olds, news] = [block.a1 - block.a0, block.b1 - block.b0]
|
|
57
|
-
if (olds
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
x = inner.a1
|
|
63
|
-
y = inner.b1
|
|
161
|
+
if (olds > 0 && news > 0) {
|
|
162
|
+
if (olds === news) for (let k = 0; k < olds; k++) alike(changed, ka.lines[block.a0 + k], kb.lines[block.b0 + k])
|
|
163
|
+
else {
|
|
164
|
+
const pairs = kept(trimmed(ka.keys, block.a0, block.a1), trimmed(kb.keys, block.b0, block.b1), reading(ka, block.a0), reading(kb, block.b0))
|
|
165
|
+
for (const [x, y] of [...pairs].filter((pair) => !pair[2])) alike(changed, ka.lines[block.a0 + x], kb.lines[block.b0 + y])
|
|
64
166
|
}
|
|
167
|
+
for (const [variable, other] of placed(ka, block.a0, block.a1, kb, block.b0, block.b1)) counted(tokened, variable, other)
|
|
65
168
|
}
|
|
66
169
|
i = block.a1
|
|
67
170
|
j = block.b1
|
|
68
171
|
}
|
|
69
|
-
return { changed, unchanged }
|
|
172
|
+
return { changed, tokened, unchanged }
|
|
70
173
|
}
|
|
71
174
|
|
|
72
175
|
// The most counted of those not yet paired, none where two tie.
|
|
@@ -101,11 +204,22 @@ function matched({ backward, forward }, { map, paired }, eligible = () => true)
|
|
|
101
204
|
}
|
|
102
205
|
|
|
103
206
|
// Unchanged lines pair first; changed ones then pair only what those say
|
|
104
|
-
// nothing of,
|
|
207
|
+
// nothing of, and a change's tokens only what no line says anything of: lines
|
|
208
|
+
// reordered, a change's tokens may line up across them. Both are `weak` pairs,
|
|
209
|
+
// the first to give way where names read wrongly. A second pass reads the
|
|
210
|
+
// pairs found as names, so lines alike with every name set aside and diffed
|
|
211
|
+
// against the wrong one line up by those.
|
|
105
212
|
export function pairing(before, after, a, b) {
|
|
106
|
-
const {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
213
|
+
const found = { map: new Map(), paired: new Set() }, said = new Set(), weak = new Set()
|
|
214
|
+
const passes = [[() => null, () => null], [(variable) => found.map.get(variable)?.name ?? null, (variable) => (found.paired.has(variable) ? variable.name : null)]]
|
|
215
|
+
for (const [old, now] of passes) {
|
|
216
|
+
// A pass pairs nothing where every variable of a side is paired already.
|
|
217
|
+
if (!a.variables.some((variable) => variable.aside && !found.map.has(variable)) || !b.variables.some((variable) => variable.aside && !found.paired.has(variable))) break
|
|
218
|
+
const { changed, tokened, unchanged } = tallied(keyed(before, a, old), keyed(after, b, now))
|
|
219
|
+
for (const [counts, tier] of [[unchanged, false], [changed, true], [tokened, true]]) {
|
|
220
|
+
for (const variable of matched(counts, found, (from, to) => !said.has(from) && !said.has(to))) if (tier) weak.add(variable)
|
|
221
|
+
for (const variable of [...counts.forward.keys(), ...counts.backward.keys()]) said.add(variable)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
110
224
|
return { map: found.map, weak }
|
|
111
225
|
}
|
package/src/names/rename.js
CHANGED
|
@@ -53,7 +53,10 @@ function renamedNames(before, after, { extension = 'js', otherExtension = extens
|
|
|
53
53
|
if (!bindsAlike(before, text, a)) return { reason: 'unverified' }
|
|
54
54
|
const changed = a.variables.filter((variable) => given.get(variable) !== variable.name)
|
|
55
55
|
const renamed = changed.filter((variable) => paired.map.has(variable)).length
|
|
56
|
-
|
|
56
|
+
// A read of names can act otherwise only where one is renamed, and `.name` reads a function's or
|
|
57
|
+
// class's alone: one only where a name renamed is one's.
|
|
58
|
+
const naming = changed.some((variable) => variable.naming || paired.map.get(variable)?.naming)
|
|
59
|
+
const reads = changed.length === 0 ? [] : [...new Set([...a.reads, ...b.reads])].filter((read) => read !== 'name' || naming).toSorted()
|
|
57
60
|
return { text, renamed, apart: changed.length - renamed, kind: { module: a.module, commonjs: a.commonjs, own: a.own }, reads }
|
|
58
61
|
}
|
|
59
62
|
|
package/src/oxc.js
CHANGED
package/src/oxc.node.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module'
|
|
|
3
3
|
// oxc-parser and oxc-minify, optional peers, required on first use; one missing
|
|
4
4
|
// is thrown as the environment's error, not taken for a text that won't parse.
|
|
5
5
|
const require = createRequire(import.meta.url)
|
|
6
|
-
let oxc
|
|
6
|
+
let oxc, parser
|
|
7
7
|
|
|
8
8
|
function load(name) {
|
|
9
9
|
try {
|
|
@@ -14,3 +14,5 @@ function load(name) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export const getOxc = () => (oxc ??= { parseSync: load('oxc-parser').parseSync, minifySync: load('oxc-minify').minifySync })
|
|
17
|
+
// The parser alone, for what needs no minifier: a declaration file proven to hold types alone.
|
|
18
|
+
export const getParser = () => (parser ??= load('oxc-parser').parseSync)
|
package/src/pretty.js
CHANGED
|
@@ -28,7 +28,11 @@ function numberSpelling(text) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const sorted = (flags) => [...flags].toSorted().join('')
|
|
31
|
-
|
|
31
|
+
// A script's text but for what the formatter may respell, beside the syntax tree compared after:
|
|
32
|
+
// blanks, parentheses, semicolons, quotes, numbers and a regular expression's flags, and a comma before
|
|
33
|
+
// a closing bracket, past any comments (`import { a, b, }`).
|
|
34
|
+
const scriptForm = (text) => text.replaceAll(NUMBER, numberSpelling).replaceAll(/,(?=(?:\s|\/\/[^\n]*|\/\*[^]*?\*\/)*[\])}>])/gu, '')
|
|
35
|
+
.replaceAll(/\\(?=["'])|[\s();]/gu, '').replaceAll("'", '"')
|
|
32
36
|
.replaceAll(/(?<=\/)[dgimsuvy]{2,}(?![\p{L}\p{N}_$])/gu, sorted)
|
|
33
37
|
|
|
34
38
|
// JSON has no point without a digit either side: such a number is kept as written, or a copy would read as
|
|
@@ -45,7 +49,8 @@ function parsed(text, extension) {
|
|
|
45
49
|
return result.errors.length > 0 ? null : result
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
// A comment as written, but for its indentation and the blanks ending it, which formatting trims.
|
|
53
|
+
const comments = ({ comments: list }) => list.map(({ type, value }) => `${type} ${indentless(value).replace(/[\t ]+$/u, '')}`)
|
|
49
54
|
// `a && (b && c)` and `(a && b) && c` read alike, as do `||` and `??` chains.
|
|
50
55
|
function chain(node, operator) {
|
|
51
56
|
const operands = [], rest = [node]
|
|
@@ -79,14 +84,42 @@ function sameTree(a, b) {
|
|
|
79
84
|
return true
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
// A script's types as the formatter writes them: a member's separator `;` where it was `,`
|
|
88
|
+
// (`{ a: string, b: number }`), and no `|` or `&` before a union's or an intersection's first type
|
|
89
|
+
// (`type P =\n | A\n | B`, `[K in\n | A\n | B]`). oxc reads a member with the separator ending it
|
|
90
|
+
// and a union from the mark leading it, so each is taken where it is, in whatever context.
|
|
91
|
+
function typed(text, program) {
|
|
92
|
+
const edits = [], work = [program]
|
|
93
|
+
while (work.length > 0) {
|
|
94
|
+
const node = work.pop()
|
|
95
|
+
// Pushed one by one: a spread of a file's statements could pass more arguments than a call takes.
|
|
96
|
+
if (Array.isArray(node)) for (const item of node) work.push(item)
|
|
97
|
+
else if (node !== null && typeof node === 'object') {
|
|
98
|
+
const members = node.type === 'TSTypeLiteral' ? node.members : node.type === 'TSInterfaceBody' ? node.body : []
|
|
99
|
+
for (const member of members) if (text[member.end - 1] === ',') edits.push([member.end - 1, ';'])
|
|
100
|
+
const mark = node.type === 'TSUnionType' ? '|' : node.type === 'TSIntersectionType' ? '&' : null
|
|
101
|
+
if (mark && text[node.start] === mark && node.types[0]?.start > node.start) edits.push([node.start, ''])
|
|
102
|
+
for (const key of Object.keys(node)) if (!POSITION.has(key) && typeof node[key] === 'object') work.push(node[key])
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let from = 0, out = ''
|
|
106
|
+
for (const [at, put] of edits.toSorted(([x], [y]) => x - y)) {
|
|
107
|
+
out += text.slice(from, at) + put
|
|
108
|
+
from = at + 1
|
|
109
|
+
}
|
|
110
|
+
return out + text.slice(from)
|
|
111
|
+
}
|
|
112
|
+
|
|
82
113
|
// A safeguard on oxfmt's own output, not a comparison of any two texts: exported
|
|
83
114
|
// for tests alone, not by the package. CSS is read by `csstree` where given.
|
|
84
115
|
export function sameCode(text, formatted, extension = 'js', csstree = null) {
|
|
85
116
|
if (extension === 'css') return csstree !== null && sameCss(csstree, text, formatted)
|
|
86
117
|
if (extension === 'json') return sameList(jsonForm(text), jsonForm(formatted))
|
|
87
|
-
if (!SCRIPTS.has(extension)
|
|
118
|
+
if (!SCRIPTS.has(extension)) return false
|
|
88
119
|
const [before, after] = [parsed(text, extension), parsed(formatted, extension)]
|
|
89
|
-
|
|
120
|
+
if (before === null || after === null) return false
|
|
121
|
+
const form = (code, { program }) => scriptForm(/^[cm]?tsx?$/u.test(extension) ? typed(code, program) : code)
|
|
122
|
+
return form(text, before) === form(formatted, after) && sameList(comments(before), comments(after)) && sameTree(before.program, after.program)
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
// A script's block comments over more than one line, a license's say, each end their line: what oxfmt
|
package/src/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// types.node.js where Node does not run this: with no type stripping of Node's to hold oxc's tree to,
|
|
2
|
+
// no text is proven to hold types alone, and neither oxc nor anything else is loaded for it.
|
|
3
|
+
|
|
4
|
+
export function proveTypeOnly(text) {
|
|
5
|
+
if (typeof text !== 'string') throw new TypeError(`a text must be a string, not ${typeof text}`)
|
|
6
|
+
if (!text.isWellFormed()) throw new TypeError('a text must be a well-formed string')
|
|
7
|
+
return { proven: false, reason: 'unsupported', at: null }
|
|
8
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Whether a text holds types alone, proven on Node: Node runs a `.d.ts` it imports as the `.ts` it is
|
|
2
|
+
// named, what its type stripping leaves of it, so oxc's syntax tree and that stripping itself must each
|
|
3
|
+
// find nothing but types in it, the same to the character; what stripping leaves is read again apart from
|
|
4
|
+
// either, and so is what its transform mode (--experimental-transform-types) leaves. The portable twin,
|
|
5
|
+
// types.js, proves nothing and loads nothing. oxc-parser, an optional peer, is loaded on first use, and
|
|
6
|
+
// without it nothing is proven: compareTrees, which stamps declaration files, needs no peer.
|
|
7
|
+
|
|
8
|
+
import * as nodeModule from 'node:module'
|
|
9
|
+
import { getParser } from '#oxc'
|
|
10
|
+
import { CODE, ERASED, IMPORT, INERT, leftover, strippedAs } from './erasure.js'
|
|
11
|
+
|
|
12
|
+
// Each a fresh result, never a shared object a caller could mutate into another's.
|
|
13
|
+
const unsupported = () => ({ proven: false, reason: 'unsupported', at: null })
|
|
14
|
+
const refused = (reason, at) => ({ proven: false, reason, at })
|
|
15
|
+
|
|
16
|
+
// What stripping writes in place of what it erases is a blank of as many UTF-8 bytes (a space, a no-break
|
|
17
|
+
// space, an en space; a space and a byte order mark for four), unit for unit; line breaks and other blanks
|
|
18
|
+
// it keeps. Any blank but a line break is taken for one.
|
|
19
|
+
const WRITTEN = /[^\S\n\r\u2028\u2029]/u
|
|
20
|
+
const LINE_BREAK = /[\n\r\u2028\u2029]/u
|
|
21
|
+
const BLANK = /\s/u
|
|
22
|
+
|
|
23
|
+
// Node's stripping is one instance for the process, its loader of `.ts` files' too, and one stopped partway
|
|
24
|
+
// (a stack overflowed, execution terminated) is not trusted again: one that threw here anything but its
|
|
25
|
+
// refusal of a text, or that does not strip the canary as it should, as one stopped elsewhere may not.
|
|
26
|
+
const untrusted = new WeakSet()
|
|
27
|
+
const REFUSALS = new Set(['ERR_INVALID_TYPESCRIPT_SYNTAX', 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'])
|
|
28
|
+
const CANARY = ['let a: "\u00E9\u4E00\u{1F600}" = 1 // c\n', 'let a \u00A0\u2002 \uFEFF = 1 // c\n']
|
|
29
|
+
|
|
30
|
+
function parser() {
|
|
31
|
+
try {
|
|
32
|
+
return getParser()
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Node's stripping in either mode: null where this Node has none, built without it.
|
|
39
|
+
function strip(stripTypeScriptTypes, text, mode) {
|
|
40
|
+
try {
|
|
41
|
+
return { code: stripTypeScriptTypes(text, { mode }) }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code === 'ERR_NO_TYPESCRIPT') return null
|
|
44
|
+
if (!REFUSALS.has(error?.code)) untrusted.add(stripTypeScriptTypes)
|
|
45
|
+
return { error }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trusted(stripTypeScriptTypes) {
|
|
50
|
+
if (!untrusted.has(stripTypeScriptTypes) && strip(stripTypeScriptTypes, CANARY[0], 'strip')?.code === CANARY[1]) return true
|
|
51
|
+
untrusted.add(stripTypeScriptTypes)
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// oxc's tree, or null where oxc throws rather than tells an error.
|
|
56
|
+
function parsed(parseSync, text) {
|
|
57
|
+
try {
|
|
58
|
+
return parseSync('file.ts', text, { lang: 'ts', sourceType: 'module' })
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Where stripping and oxc's tree do not agree, or -1: stripping blanks what it erases and keeps line
|
|
65
|
+
// breaks, and what it keeps that is no blank lies in a comment or the hashbang outside the statements
|
|
66
|
+
// oxc finds erased, or in an inert one. A comment within an erased statement goes with it.
|
|
67
|
+
function disagreement(text, code, { comments, program }, { erased, inert }) {
|
|
68
|
+
const kept = new Uint8Array(text.length)
|
|
69
|
+
for (const { start, end } of [...comments, ...inert, ...(program.hashbang ? [program.hashbang] : [])]) kept.fill(1, start, end)
|
|
70
|
+
for (const { start, end } of erased) kept.fill(0, start, end)
|
|
71
|
+
// A character stripping changed must become a blank, not a line break written over one; one it kept
|
|
72
|
+
// must be a blank, or lie in a span.
|
|
73
|
+
for (let i = 0; i < text.length; i++) {
|
|
74
|
+
if (code[i] === text[i] ? !kept[i] && !BLANK.test(code[i]) : !WRITTEN.test(code[i]) || LINE_BREAK.test(text[i])) return i
|
|
75
|
+
}
|
|
76
|
+
return -1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// proveTypeOnly with the stripping given: exported for tests alone, not by the package.
|
|
80
|
+
export function proveWith(text, stripTypeScriptTypes) {
|
|
81
|
+
if (typeof text !== 'string') throw new TypeError(`a text must be a string, not ${typeof text}`)
|
|
82
|
+
if (!text.isWellFormed()) throw new TypeError('a text must be a well-formed string')
|
|
83
|
+
const parseSync = parser()
|
|
84
|
+
if (typeof stripTypeScriptTypes !== 'function' || parseSync === null || !trusted(stripTypeScriptTypes)) return unsupported()
|
|
85
|
+
// Stripping drops a byte order mark the text starts with.
|
|
86
|
+
const shift = text.startsWith('\uFEFF') ? 1 : 0
|
|
87
|
+
const body = text.slice(shift)
|
|
88
|
+
// Stripping goes first: oxc's parser, native, overflows its stack on nesting deep enough and ends the
|
|
89
|
+
// process, where stripping throws first, at less than half the depth on an 8 MB stack. oxc reads only
|
|
90
|
+
// what stripping read whole: it either strips it, or refuses what it does not erase once it has read it all.
|
|
91
|
+
const stripped = strip(stripTypeScriptTypes, body, 'strip')
|
|
92
|
+
if (stripped === null) return unsupported()
|
|
93
|
+
if ('error' in stripped && stripped.error?.code !== 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') return refused('unparsed', null)
|
|
94
|
+
const tree = parsed(parseSync, body)
|
|
95
|
+
if (tree === null) return refused('unparsed', null)
|
|
96
|
+
if (tree.errors.length > 0) return refused('unparsed', shift + (tree.errors[0].labels?.[0]?.start ?? 0))
|
|
97
|
+
const spans = { [ERASED]: [], [INERT]: [] }
|
|
98
|
+
for (const statement of tree.program.body) {
|
|
99
|
+
const kind = strippedAs(statement)
|
|
100
|
+
if (kind === IMPORT || kind === CODE) return refused(kind, shift + statement.start)
|
|
101
|
+
spans[kind].push(statement)
|
|
102
|
+
}
|
|
103
|
+
if ('error' in stripped || stripped.code.length !== body.length) return refused('disagree', null)
|
|
104
|
+
const at = disagreement(body, stripped.code, tree, spans)
|
|
105
|
+
const left = at === -1 ? leftover(stripped.code) : at
|
|
106
|
+
if (left !== -1) return refused('disagree', shift + left)
|
|
107
|
+
const transformed = strip(stripTypeScriptTypes, body, 'transform')
|
|
108
|
+
if (transformed === null) return unsupported()
|
|
109
|
+
// A retained `export {}` runs nothing as a module but emits a marker transpiled to CommonJS, so the file
|
|
110
|
+
// is types-but-for-it: `inert`, stamped by compareTrees only where both sides of a change match.
|
|
111
|
+
return 'code' in transformed && leftover(transformed.code) === -1 ? { proven: true, inert: spans[INERT].length > 0 } : refused('disagree', null)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const proveTypeOnly = (text) => proveWith(text, nodeModule.stripTypeScriptTypes)
|