@code-yeongyu/senpi-codemode 2026.9.23 → 2026.9.24-3
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/CHANGELOG.md +98 -0
- package/README.md +16 -10
- package/package.json +4 -4
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/code-preview.ts +31 -0
- package/src/tool/display-code.ts +48 -0
- package/src/tool/display-js-ast.ts +65 -0
- package/src/tool/display-js-layout.ts +90 -0
- package/src/tool/display-js-mask.ts +178 -0
- package/src/tool/display-js.ts +100 -0
- package/src/tool/display-python-script.ts +127 -0
- package/src/tool/display-python.ts +150 -0
- package/src/tool/eval-request.ts +24 -15
- package/src/tool/eval-tool.ts +3 -3
- package/src/tool/render.ts +45 -58
- package/src/tool/types.ts +17 -13
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,104 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.24-3] - 2026-09-24
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
## [2026.9.24-2] - 2026-09-24
|
|
28
|
+
|
|
29
|
+
### Breaking Changes
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Dense JavaScript eval cells are previewed with Bun's built-in printer when the TUI runs on Bun: one statement per line with normal spacing, while every string, number, template, name, and comment keeps its exact text. On Node, JavaScript cells are shown as sent. Dense Python cells are formatted by your own Python (ruff or black when installed, otherwise the standard library for comment-free cells), appearing formatted a moment after the cell is shown. Anything that cannot be shown faithfully is shown as sent, and the code that runs is unchanged. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- The `eval` tool's run contract is no longer a trap: the schema descriptions now mark `language` and `code` as required for run, and a run call missing either fails with an error that names the enabled languages or the missing cell body instead of a bare "eval run requires language".
|
|
40
|
+
- An omitted `eval` `language` now names only the kernels enabled in this session (`js` and `py` by default), instead of advertising gated Ruby and Julia.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
## [2026.9.24] - 2026-09-24
|
|
45
|
+
|
|
46
|
+
### Breaking Changes
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Eval code previews no longer show code that means something different from the cell: breaking a long JavaScript array keeps the parentheses around each element, and a Python formatter result that changes string or docstring text (or is not valid Python) is discarded so the cell is shown as sent. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
|
|
55
|
+
- Python eval previews give your ruff most of the 5-second formatting budget instead of a fixed 4 seconds, so a slow first ruff start still formats the cell. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
|
|
56
|
+
|
|
57
|
+
### Removed
|
|
58
|
+
|
|
59
|
+
## [2026.9.23-5] - 2026-09-23
|
|
60
|
+
|
|
61
|
+
### Breaking Changes
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
### Removed
|
|
70
|
+
|
|
71
|
+
## [2026.9.23-4] - 2026-09-23
|
|
72
|
+
|
|
73
|
+
### Breaking Changes
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
|
|
79
|
+
- Eval cards no longer display truncation or full-output artifact footer warnings. Model-facing eval text and grouping are unchanged; content explicitly addressed only to the model is omitted from the text fallback. ([#2041](https://github.com/code-yeongyu/senpi/issues/2041))
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
### Removed
|
|
84
|
+
|
|
85
|
+
## [2026.9.23-3] - 2026-09-23
|
|
86
|
+
|
|
87
|
+
### Breaking Changes
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
### Changed
|
|
92
|
+
|
|
93
|
+
- Dense one-line JavaScript eval cells are previewed in the TUI broken at statement, block, and long-array boundaries with indentation, keeping the original tokens and comments. Unparseable, non-JavaScript, and already readable cells are shown as sent, and the code that runs is unchanged. ([#2050](https://github.com/code-yeongyu/senpi/issues/2050))
|
|
94
|
+
- The `eval` `summary` guide asks for a progress update saying what the agent is doing and why, in the language the user writes in, and summaries no longer have a length limit (the 80-character truncation is gone). A collapsed eval block shows the first three summary lines; expanding it shows the rest. ([#2050](https://github.com/code-yeongyu/senpi/issues/2050))
|
|
95
|
+
- Codemode CI tests use at most two fork workers, matching the coding-agent suite's interpreter-heavy test scheduling. Test deadlines, assertions, and local worker defaults are unchanged. ([#2039](https://github.com/code-yeongyu/senpi/issues/2039))
|
|
96
|
+
|
|
97
|
+
### Fixed
|
|
98
|
+
|
|
99
|
+
### Removed
|
|
100
|
+
|
|
101
|
+
## [2026.9.23-2] - 2026-09-23
|
|
102
|
+
|
|
103
|
+
### Breaking Changes
|
|
104
|
+
|
|
105
|
+
### Added
|
|
106
|
+
|
|
107
|
+
### Changed
|
|
108
|
+
|
|
109
|
+
### Fixed
|
|
110
|
+
|
|
111
|
+
### Removed
|
|
112
|
+
|
|
15
113
|
## [2026.9.23] - 2026-09-23
|
|
16
114
|
|
|
17
115
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -29,7 +29,9 @@ task-tool names are known.
|
|
|
29
29
|
session-adjacent spill files for large streams.
|
|
30
30
|
- TUI and HTML-export rendering for syntax-highlighted cells, status rows,
|
|
31
31
|
task progress, structured display values, truncation warnings, and image
|
|
32
|
-
fallbacks.
|
|
32
|
+
fallbacks. A JavaScript cell sent as dense one-line code is previewed broken
|
|
33
|
+
at statement, block, and long-array boundaries; the cell itself runs exactly
|
|
34
|
+
as sent.
|
|
33
35
|
- Runtime identity badges in eval headers — `eval py (3.14.7, ~/.venv/bin/python3)`,
|
|
34
36
|
`eval js (node 26.7.0, /opt/…/bin/node)` — with the same `runtime` info on
|
|
35
37
|
`EvalToolDetails` and its `cells` for RPC consumers; interpreter detection
|
|
@@ -205,15 +207,19 @@ that implements it. Reset only removes kernel variables: save `pool_id` and use
|
|
|
205
207
|
arguments in other languages). An open pool's adapter can be recreated with the
|
|
206
208
|
same name/spec/mode; no worker state is reconstructed in the prelude.
|
|
207
209
|
|
|
208
|
-
## Required
|
|
209
|
-
|
|
210
|
-
Every `eval` run call MUST include a `
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
210
|
+
## Required run fields
|
|
211
|
+
|
|
212
|
+
Every `eval` run call MUST include a `language` (the kernel that runs the
|
|
213
|
+
cell, one of the enabled languages), the `code` cell body, and a `summary` —
|
|
214
|
+
one line in the language the
|
|
215
|
+
user writes in: a progress update saying what the agent is doing and why, not
|
|
216
|
+
a label for the code. The
|
|
217
|
+
summary is shown in the TUI while the cell runs and in the finished result, so
|
|
218
|
+
you can always tell what is running and why. It has no length limit; a
|
|
219
|
+
collapsed block shows its first three lines. The schema marks all three
|
|
220
|
+
optional only because the control actions (`peek`, `stop`, `list`) share it; a
|
|
221
|
+
run request missing any of them fails with a teaching error that names what to
|
|
222
|
+
add.
|
|
217
223
|
|
|
218
224
|
## Detached cells
|
|
219
225
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.24-3",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.24-3",
|
|
34
34
|
"typebox": "1.3.34"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.24-3"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.24-3"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
package/src/tool/AGENTS.md
CHANGED
|
@@ -14,6 +14,7 @@ anchor most suites).
|
|
|
14
14
|
| Cell execution, settlement | `cell-handler.ts`, `cell-execution.ts`, `cell-runtime.ts` |
|
|
15
15
|
| Detached cells | `detached-cell-manager.ts` + `detached-cell-{state,snapshot,notification}.ts`, `detached-notification-queue.ts`, `detached-eval-result.ts` |
|
|
16
16
|
| Call/result rendering | `render.ts`, `runtime-label.ts`, `json-tree.ts`, `image.ts`, `tool-widgets.ts` |
|
|
17
|
+
| Code preview layout | `code-preview.ts` -> `display-code.ts` (JS: `display-js*.ts` Bun-only masked `Bun.Transpiler`; Python: `display-python*.ts` user interpreter, background + repaint) |
|
|
17
18
|
| Status events, execution events | `status-events.ts`, `eval-execution-event.ts` |
|
|
18
19
|
| Interrupt, capture | `interrupt-note.ts`, `call-capture.ts` |
|
|
19
20
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { highlightCode, type Theme } from "@code-yeongyu/senpi";
|
|
2
|
+
import { displayCode } from "./display-code.ts";
|
|
3
|
+
import type { EvalLanguage } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
function languageForHighlighter(language: EvalLanguage): "python" | "javascript" | "ruby" | "julia" {
|
|
6
|
+
switch (language) {
|
|
7
|
+
case "py":
|
|
8
|
+
return "python";
|
|
9
|
+
case "js":
|
|
10
|
+
return "javascript";
|
|
11
|
+
case "rb":
|
|
12
|
+
return "ruby";
|
|
13
|
+
case "jl":
|
|
14
|
+
return "julia";
|
|
15
|
+
default: {
|
|
16
|
+
const unreachable: never = language;
|
|
17
|
+
throw new TypeError(`Unhandled eval language: ${String(unreachable)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function highlightedCode(
|
|
23
|
+
code: string,
|
|
24
|
+
language: EvalLanguage,
|
|
25
|
+
theme: Theme | undefined,
|
|
26
|
+
onFormatted?: () => void,
|
|
27
|
+
): string {
|
|
28
|
+
const normalizedCode = code.trim().length > 0 ? code : "...";
|
|
29
|
+
const lines = highlightCode(displayCode(normalizedCode, language, onFormatted), languageForHighlighter(language));
|
|
30
|
+
return (theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines).join("\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { bunJsPrinter, prettifyJs } from "./display-js.ts";
|
|
2
|
+
import { pythonDisplay } from "./display-python.ts";
|
|
3
|
+
import type { EvalLanguage } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// Display-only (senpi#2050, senpi#2076): models often send a cell as one long line of joined
|
|
6
|
+
// statements. The tool arguments are never touched (senpi#1472).
|
|
7
|
+
const DENSE_LINE_LENGTH = 100;
|
|
8
|
+
const CACHE_LIMIT = 64;
|
|
9
|
+
|
|
10
|
+
const jsCache = new Map<string, string>();
|
|
11
|
+
|
|
12
|
+
function isDense(code: string): boolean {
|
|
13
|
+
return code.split("\n").some((line) => line.length > DENSE_LINE_LENGTH);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function displayJs(code: string): string {
|
|
17
|
+
const print = bunJsPrinter();
|
|
18
|
+
if (print === null) return code;
|
|
19
|
+
const cached = jsCache.get(code);
|
|
20
|
+
if (cached !== undefined) return cached;
|
|
21
|
+
const formatted = prettifyJs(code, print) ?? code;
|
|
22
|
+
if (jsCache.size >= CACHE_LIMIT) jsCache.delete(jsCache.keys().next().value ?? "");
|
|
23
|
+
jsCache.set(code, formatted);
|
|
24
|
+
return formatted;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The preview text for a cell. Dense JavaScript is laid out by the built-in Bun printer only when
|
|
29
|
+
* the renderer runs on Bun; dense Python is formatted by the user's own interpreter in the
|
|
30
|
+
* background, so the first render shows the cell as sent and `onFormatted` asks for a repaint.
|
|
31
|
+
* Pass `onFormatted` only for complete code; without it no formatter process starts.
|
|
32
|
+
*/
|
|
33
|
+
export function displayCode(code: string, language: EvalLanguage, onFormatted?: () => void): string {
|
|
34
|
+
if (!isDense(code)) return code;
|
|
35
|
+
switch (language) {
|
|
36
|
+
case "js":
|
|
37
|
+
return displayJs(code);
|
|
38
|
+
case "py":
|
|
39
|
+
return pythonDisplay.display(code, onFormatted);
|
|
40
|
+
case "rb":
|
|
41
|
+
case "jl":
|
|
42
|
+
return code;
|
|
43
|
+
default: {
|
|
44
|
+
const unreachable: never = language;
|
|
45
|
+
return unreachable;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
|
|
3
|
+
export type SourceNode = {
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Edit = { readonly start: number; readonly end: number; readonly text: string };
|
|
11
|
+
|
|
12
|
+
export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sourceNode(value: unknown): SourceNode | undefined {
|
|
17
|
+
if (!isRecord(value)) return undefined;
|
|
18
|
+
const { type, start, end } = value;
|
|
19
|
+
if (typeof type !== "string" || typeof start !== "number" || typeof end !== "number") return undefined;
|
|
20
|
+
return { type, start, end, record: value };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sourceNodes(value: unknown): SourceNode[] {
|
|
24
|
+
if (!Array.isArray(value)) return [];
|
|
25
|
+
return value.flatMap((item) => sourceNode(item) ?? []);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function childNodes(node: SourceNode): SourceNode[] {
|
|
29
|
+
return Object.entries(node.record).flatMap(([key, value]) => {
|
|
30
|
+
if (key === "loc" || key === "extra" || key.endsWith("Comments")) return [];
|
|
31
|
+
return Array.isArray(value) ? sourceNodes(value) : (sourceNode(value) ?? []);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parses a cell the way the js kernel accepts it: module syntax, top-level `await` and `return`. */
|
|
36
|
+
export function parseFile(code: string): SourceNode | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return sourceNode(
|
|
39
|
+
parse(code, {
|
|
40
|
+
sourceType: "module",
|
|
41
|
+
allowAwaitOutsideFunction: true,
|
|
42
|
+
allowReturnOutsideFunction: true,
|
|
43
|
+
allowImportExportEverywhere: true,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof SyntaxError) return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function programOf(file: SourceNode | undefined): SourceNode | undefined {
|
|
53
|
+
return file === undefined ? undefined : sourceNode(file.record.program);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function applyEdits(code: string, edits: readonly Edit[]): string {
|
|
57
|
+
const sorted = [...edits].sort((left, right) => left.start - right.start);
|
|
58
|
+
let output = "";
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
for (const edit of sorted) {
|
|
61
|
+
output += code.slice(cursor, edit.start) + edit.text;
|
|
62
|
+
cursor = edit.end;
|
|
63
|
+
}
|
|
64
|
+
return output + code.slice(cursor);
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyEdits,
|
|
3
|
+
childNodes,
|
|
4
|
+
type Edit,
|
|
5
|
+
parseFile,
|
|
6
|
+
programOf,
|
|
7
|
+
type SourceNode,
|
|
8
|
+
sourceNode,
|
|
9
|
+
sourceNodes,
|
|
10
|
+
} from "./display-js-ast.ts";
|
|
11
|
+
|
|
12
|
+
const ARRAY_BREAK_LENGTH = 60;
|
|
13
|
+
const INDENT = " ";
|
|
14
|
+
|
|
15
|
+
function forHeaderEdits(code: string, node: SourceNode, edits: Edit[]): void {
|
|
16
|
+
if (node.type === "ForStatement") {
|
|
17
|
+
const body = sourceNode(node.record.body);
|
|
18
|
+
const open = code.indexOf("(", node.start);
|
|
19
|
+
const close = body === undefined ? -1 : code.lastIndexOf(")", body.start);
|
|
20
|
+
if (open >= 0 && close > open) {
|
|
21
|
+
const parts = ["init", "test", "update"].map((key) => {
|
|
22
|
+
const part = sourceNode(node.record[key]);
|
|
23
|
+
return part === undefined ? "" : code.slice(part.start, part.end);
|
|
24
|
+
});
|
|
25
|
+
const header = parts.every((part) => part.length === 0) ? ";;" : parts.join("; ");
|
|
26
|
+
if (code.slice(open + 1, close) !== header) edits.push({ start: open + 1, end: close, text: header });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const child of childNodes(node)) forHeaderEdits(code, child, edits);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lineIndent(code: string, position: number): string {
|
|
33
|
+
const line = code.slice(code.lastIndexOf("\n", position - 1) + 1);
|
|
34
|
+
return line.slice(0, line.length - line.trimStart().length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Only whitespace in a gap is rewritten: a gap between babel element nodes can also hold the
|
|
38
|
+
// parentheses of a parenthesized element, and those must stay (senpi#2076).
|
|
39
|
+
function gapTokens(code: string, start: number, end: number): string {
|
|
40
|
+
return code.slice(start, end).replace(/\s+/gu, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A one-line array longer than ARRAY_BREAK_LENGTH puts each element on its own line (senpi#2050).
|
|
44
|
+
// `forcedIndent` is the indentation of an element line an enclosing break just created.
|
|
45
|
+
function arrayEdits(code: string, node: SourceNode, forcedIndent: string | undefined, edits: Edit[]): void {
|
|
46
|
+
const rawElements = node.record.elements;
|
|
47
|
+
const elements = sourceNodes(rawElements);
|
|
48
|
+
const first = elements[0];
|
|
49
|
+
const last = elements.at(-1);
|
|
50
|
+
const text = code.slice(node.start, node.end);
|
|
51
|
+
const breaks =
|
|
52
|
+
node.type === "ArrayExpression" &&
|
|
53
|
+
Array.isArray(rawElements) &&
|
|
54
|
+
elements.length === rawElements.length &&
|
|
55
|
+
elements.length >= 2 &&
|
|
56
|
+
text.length > ARRAY_BREAK_LENGTH &&
|
|
57
|
+
!text.includes("\n");
|
|
58
|
+
if (!breaks || first === undefined || last === undefined) {
|
|
59
|
+
for (const child of childNodes(node)) arrayEdits(code, child, forcedIndent, edits);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const base = forcedIndent ?? lineIndent(code, node.start);
|
|
63
|
+
const inner = `${base}${INDENT}`;
|
|
64
|
+
edits.push({
|
|
65
|
+
start: node.start + 1,
|
|
66
|
+
end: first.start,
|
|
67
|
+
text: `\n${inner}${gapTokens(code, node.start + 1, first.start)}`,
|
|
68
|
+
});
|
|
69
|
+
elements.slice(1).forEach((element, index) => {
|
|
70
|
+
const start = elements[index]?.end ?? element.start;
|
|
71
|
+
const [before = "", after = ""] = gapTokens(code, start, element.start).split(",");
|
|
72
|
+
edits.push({ start, end: element.start, text: `${before},\n${inner}${after}` });
|
|
73
|
+
});
|
|
74
|
+
edits.push({ start: last.end, end: node.end - 1, text: `${gapTokens(code, last.end, node.end - 1)}\n${base}` });
|
|
75
|
+
for (const element of elements) arrayEdits(code, element, inner, edits);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Printer fix-ups on the restored cell: `for (a; b; c)` headers, then long-array breaks. */
|
|
79
|
+
export function finishLayout(code: string): string | undefined {
|
|
80
|
+
const program = programOf(parseFile(code));
|
|
81
|
+
if (program === undefined) return undefined;
|
|
82
|
+
const headerEdits: Edit[] = [];
|
|
83
|
+
forHeaderEdits(code, program, headerEdits);
|
|
84
|
+
const withHeaders = applyEdits(code, headerEdits);
|
|
85
|
+
const reparsed = programOf(parseFile(withHeaders));
|
|
86
|
+
if (reparsed === undefined) return undefined;
|
|
87
|
+
const breaks: Edit[] = [];
|
|
88
|
+
arrayEdits(withHeaders, reparsed, undefined, breaks);
|
|
89
|
+
return applyEdits(withHeaders, breaks);
|
|
90
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { applyEdits, childNodes, type Edit, type SourceNode, sourceNode } from "./display-js-ast.ts";
|
|
2
|
+
|
|
3
|
+
const LITERAL_TYPES = new Set([
|
|
4
|
+
"StringLiteral",
|
|
5
|
+
"NumericLiteral",
|
|
6
|
+
"BigIntLiteral",
|
|
7
|
+
"RegExpLiteral",
|
|
8
|
+
"BooleanLiteral",
|
|
9
|
+
"NullLiteral",
|
|
10
|
+
"TemplateLiteral",
|
|
11
|
+
"TaggedTemplateExpression",
|
|
12
|
+
"DirectiveLiteral",
|
|
13
|
+
]);
|
|
14
|
+
// One placeholder per spelling, so `break outer` still resolves against `outer:` for Bun's parser.
|
|
15
|
+
const NAME_TYPES = new Set(["Identifier", "PrivateName"]);
|
|
16
|
+
// The grammar requires a string literal under these parents (module specifiers, attributes, and
|
|
17
|
+
// string export names), so the placeholder stays a string there.
|
|
18
|
+
const STRING_SLOT_PARENTS = new Set([
|
|
19
|
+
"ImportDeclaration",
|
|
20
|
+
"ExportAllDeclaration",
|
|
21
|
+
"ExportNamedDeclaration",
|
|
22
|
+
"ImportAttribute",
|
|
23
|
+
"ImportSpecifier",
|
|
24
|
+
"ExportSpecifier",
|
|
25
|
+
"ExportNamespaceSpecifier",
|
|
26
|
+
"ExportDefaultSpecifier",
|
|
27
|
+
]);
|
|
28
|
+
const STATEMENT_LISTS = new Set(["Program", "BlockStatement", "StaticBlock", "ClassBody", "SwitchCase"]);
|
|
29
|
+
|
|
30
|
+
type SourceComment = { readonly start: number; readonly end: number; readonly line: boolean };
|
|
31
|
+
type MaskedRange = SourceNode & { readonly stringSlot: boolean };
|
|
32
|
+
export type Placeholder = { readonly id: string; readonly raw: string; readonly expected: number };
|
|
33
|
+
export type CommentPlaceholder = Placeholder & { readonly trailing: boolean };
|
|
34
|
+
export type Mask = {
|
|
35
|
+
readonly prefix: string;
|
|
36
|
+
readonly masked: string;
|
|
37
|
+
readonly values: ReadonlyMap<string, Placeholder>;
|
|
38
|
+
readonly comments: readonly CommentPlaceholder[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function fileComments(file: SourceNode): SourceComment[] | undefined {
|
|
42
|
+
const comments = file.record.comments;
|
|
43
|
+
if (!Array.isArray(comments)) return undefined;
|
|
44
|
+
const parsed: SourceComment[] = [];
|
|
45
|
+
for (const comment of comments) {
|
|
46
|
+
const node = sourceNode(comment);
|
|
47
|
+
if (node === undefined) return undefined;
|
|
48
|
+
parsed.push({ start: node.start, end: node.end, line: node.type === "CommentLine" });
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function innermostContainer(node: SourceNode, start: number, end: number): SourceNode {
|
|
54
|
+
for (const child of childNodes(node)) {
|
|
55
|
+
if (child.start <= start && end <= child.end) return innermostContainer(child, start, end);
|
|
56
|
+
}
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sitsInStatementList(code: string, program: SourceNode, comment: SourceComment): boolean {
|
|
61
|
+
const container = innermostContainer(program, comment.start, comment.end);
|
|
62
|
+
if (!STATEMENT_LISTS.has(container.type)) return false;
|
|
63
|
+
if (container.type !== "SwitchCase") return true;
|
|
64
|
+
const test = sourceNode(container.record.test);
|
|
65
|
+
const colon = code.indexOf(":", test?.end ?? container.start);
|
|
66
|
+
return colon >= 0 && colon < comment.start;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function followsCodeOnItsLine(code: string, comment: SourceComment, comments: readonly SourceComment[]): boolean {
|
|
70
|
+
let index = comment.start - 1;
|
|
71
|
+
while (index >= 0 && code[index] !== "\n") {
|
|
72
|
+
const position = index;
|
|
73
|
+
const covering = comments.find((other) => other.start <= position && position < other.end);
|
|
74
|
+
if (covering !== undefined) {
|
|
75
|
+
index = covering.start - 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (code[index] !== " " && code[index] !== "\t") return true;
|
|
79
|
+
index -= 1;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function placeholderPrefix(code: string): string {
|
|
85
|
+
let prefix = "$display";
|
|
86
|
+
while (code.includes(prefix)) prefix = `${prefix}_`;
|
|
87
|
+
return prefix;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectRanges(node: SourceNode, parent: SourceNode | undefined, ranges: Map<string, MaskedRange>): void {
|
|
91
|
+
if (LITERAL_TYPES.has(node.type) || NAME_TYPES.has(node.type)) {
|
|
92
|
+
const stringSlot = node.type === "StringLiteral" && parent !== undefined && STRING_SLOT_PARENTS.has(parent.type);
|
|
93
|
+
const key = `${node.start}:${node.end}`;
|
|
94
|
+
if (!ranges.has(key)) ranges.set(key, { ...node, stringSlot });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const child of childNodes(node)) collectRanges(child, node, ranges);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Swaps every literal, name, and statement-level comment for an opaque placeholder so a printer
|
|
102
|
+
* can only move them. Undefined when a comment sits where a placeholder statement cannot stand.
|
|
103
|
+
*/
|
|
104
|
+
export function maskSource(code: string, file: SourceNode): Mask | undefined {
|
|
105
|
+
const program = sourceNode(file.record.program);
|
|
106
|
+
const comments = fileComments(file);
|
|
107
|
+
if (program === undefined || comments === undefined) return undefined;
|
|
108
|
+
const ranges = new Map<string, MaskedRange>();
|
|
109
|
+
collectRanges(program, undefined, ranges);
|
|
110
|
+
const prefix = placeholderPrefix(code);
|
|
111
|
+
const edits: Edit[] = [];
|
|
112
|
+
const values = new Map<string, Placeholder>();
|
|
113
|
+
const nameIds = new Map<string, string>();
|
|
114
|
+
let counter = 0;
|
|
115
|
+
for (const range of ranges.values()) {
|
|
116
|
+
const raw = code.slice(range.start, range.end);
|
|
117
|
+
const reused = NAME_TYPES.has(range.type) ? nameIds.get(raw) : undefined;
|
|
118
|
+
const id = reused ?? `${prefix}${counter++}`;
|
|
119
|
+
if (NAME_TYPES.has(range.type)) nameIds.set(raw, id);
|
|
120
|
+
values.set(id, { id, raw, expected: (values.get(id)?.expected ?? 0) + 1 });
|
|
121
|
+
edits.push({ start: range.start, end: range.end, text: range.stringSlot ? `"${id}"` : id });
|
|
122
|
+
}
|
|
123
|
+
const masked: CommentPlaceholder[] = [];
|
|
124
|
+
for (const comment of comments) {
|
|
125
|
+
if (!sitsInStatementList(code, program, comment)) return undefined;
|
|
126
|
+
const id = `${prefix}${counter++}`;
|
|
127
|
+
const trailing = comment.line && followsCodeOnItsLine(code, comment, comments);
|
|
128
|
+
masked.push({ id, raw: code.slice(comment.start, comment.end), expected: 1, trailing });
|
|
129
|
+
edits.push({ start: comment.start, end: comment.end, text: `\n${id};\n` });
|
|
130
|
+
}
|
|
131
|
+
return { prefix, masked: applyEdits(code, edits), values, comments: masked };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function restoreComments(printed: string, comments: readonly CommentPlaceholder[]): string | undefined {
|
|
135
|
+
const lines = printed.split("\n");
|
|
136
|
+
for (const comment of comments) {
|
|
137
|
+
const matches = lines.flatMap((line, index) => (line.trim() === `${comment.id};` ? [index] : []));
|
|
138
|
+
const index = matches[0];
|
|
139
|
+
if (matches.length !== 1 || index === undefined) return undefined;
|
|
140
|
+
const line = lines[index] ?? "";
|
|
141
|
+
let previous = index - 1;
|
|
142
|
+
while (previous >= 0 && (lines[previous] ?? "").trim().length === 0) previous -= 1;
|
|
143
|
+
const target = lines[previous];
|
|
144
|
+
if (comment.trailing && target !== undefined && !target.includes("//")) {
|
|
145
|
+
lines[previous] = `${target} ${comment.raw}`;
|
|
146
|
+
lines.splice(index, 1);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
lines[index] = `${line.slice(0, line.length - line.trimStart().length)}${comment.raw}`;
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function escapeRegExp(text: string): string {
|
|
155
|
+
return text.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function restoreValues(printed: string, mask: Mask): string | undefined {
|
|
159
|
+
const id = `${escapeRegExp(mask.prefix)}\\d+`;
|
|
160
|
+
const pattern = new RegExp(`"(${id})"|'(${id})'|(?<![\\w$])(${id})(?![\\w$])`, "gu");
|
|
161
|
+
const counts = new Map<string, number>();
|
|
162
|
+
let unknown = false;
|
|
163
|
+
const restored = printed.replace(pattern, (match, double: unknown, single: unknown, bare: unknown) => {
|
|
164
|
+
const key = [double, single, bare].find((group): group is string => typeof group === "string") ?? "";
|
|
165
|
+
const value = mask.values.get(key);
|
|
166
|
+
if (value === undefined) {
|
|
167
|
+
unknown = true;
|
|
168
|
+
return match;
|
|
169
|
+
}
|
|
170
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
171
|
+
return value.raw;
|
|
172
|
+
});
|
|
173
|
+
if (unknown) return undefined;
|
|
174
|
+
for (const value of mask.values.values()) {
|
|
175
|
+
if (counts.get(value.id) !== value.expected) return undefined;
|
|
176
|
+
}
|
|
177
|
+
return restored;
|
|
178
|
+
}
|