Files
Michał Pierzchała df0a0f7fd2 perf(package): strip comments from the Apple runner source the npm package ships (#2467)
* perf(package): strip comments from the Apple runner source the npm package ships

The packager copies apple/runner/** into dist/ as Swift source, removing only
its AGENT_DEVICE_RUNNER_UNIT_TESTS blocks, so doc comments and design notes were
downloaded on every install: 71.9 kB of 441.2 kB of packaged runner Swift.

Add a lexical scanner for the removal. A regex cannot do this: `//` and `/*`
open a comment only in code position, raw literals move their own delimiter and
escape with the `#` count, interpolation segments hold code and further
literals, and Swift block comments nest. A construct the scanner cannot account
for throws at packaging time instead of shipping Swift that does not compile.

* fix(package): keep Swift regex literals out of the comment scanner

`#/foo//bar/#` is a valid extended regex literal with no comment in it, but the
scanner only knew the `#"` raw-string family, so it read the literal's `//` as a
line comment and shipped `let pattern = #/foo` — Swift that does not compile.
Add `#/…/#` and `##/…/##` as a literal context: matching `#` counts, the
single- and multi-line forms, Swift's own-line rule for a multi-line closing
delimiter, and the `\/` escape that keeps one from closing early.

Bare `/…/` literals stay unresolvable, because the same `/` opens a comment,
divides, and starts a regex literal, and only the parse separates them. Where
one could begin — an expression position whose `/` is not followed by a space,
a tab or `)` — packaging throws by file and line instead of rewriting bytes it
cannot prove are code. Divisions (`width/2`, `Double(3)/Double(4)`), the
recording scripts' shebang and `(/)` keep flowing through.

* fix(package): keep the packaged runner source on the checkout's line numbers

`dist/apple/runner/**` is the Swift a user's `xcodebuild` and the runner name a
file and line in (it lands in runner.log), so those numbers are only worth
reading if they point at the same line of `apple/runner/**`. Both rewriting
passes now empty the lines they remove instead of deleting them: comment removal
(889 lines, 889 B) and the pre-existing unit-test `#if` block strip, which was
moving everything below a block by up to 883 lines (3,737 lines, 3,737 B).

`dist/apple/runner/` 555,907 B -> 488,635 B (-67,272 B, -12.1%); its Swift alone
441,196 B -> 373,924 B (-15.2%). Parity costs 4,626 B of the 71,898 B the
previous head saved.

Nothing in the repo compiles the packaged source, so a mis-lex that failed to
throw would ship Swift that does not build and no gate would see it. Add
`pnpm check:packaged-runner-swift`: it packages into a throwaway root and asserts
line-count parity plus the line of every declaration each packaged file still
carries, then runs `swiftc -parse` over all 44 files. The parse half reports
itself skipped where no Swift toolchain exists, so the gate is declared on the
macOS lane, where both halves run.
2026-09-11 12:00:02 +02:00

432 lines
15 KiB
JavaScript

// Comment removal for the Swift that the npm package ships as source (#2461). `apple/runner/**`
// is copied into `dist/` as-is apart from its unit-test `#if` blocks, so every doc comment and
// every design note is downloaded on every install — 74 kB of the 446 kB measured on v0.21.1.
//
// This is a lexical scanner, not a regex pass, because `//` and `/*` open a comment only in code
// position. String literals may contain either; a raw literal (`#"…"#`) moves its own closing
// delimiter and its interpolation opener with the `#` count, so what counts as an escape changes
// per literal; interpolation segments hold code, including further literals; extended regex
// literals (`#/…/#`) are a second delimiter family that also starts with a `#` run; and Swift
// block comments nest. A regex sees none of that, and the failure mode is a package that does not
// compile on a user's machine. Anything the scanner cannot account for therefore throws here, at
// packaging time, rather than shipping.
//
// Removal never moves a line. `dist/apple/runner/**` is the source an `xcodebuild` or runner
// failure names a file and line in, and those line numbers are only worth reading if they land on
// the same line of `apple/runner/**`, so a comment-only line is emitted empty instead of deleted.
// The blank lines cost ~1 byte each against a ~72 kB saving.
//
// Bare `/…/` regex literals are the one construct no scanner can resolve: the same `/` opens a
// comment, divides, and starts a regex literal, and which it is depends on the parse. Where one
// could start, packaging fails instead of rewriting bytes the scanner cannot prove are code.
/** A `"`/`"""` literal opener with its optional raw `#` delimiters. */
const STRING_OPENER = /(#*)("""|")/y;
/** An extended regex literal opener: a `#` run, then `/`. The `#` count sets the terminator. */
const EXTENDED_REGEX_OPENER = /(#+)\//y;
/** How much emitted output `isExpressionPosition` may look back over. */
const CODE_TAIL_LENGTH = 128;
/**
* Swift bars a bare regex literal from opening on one of these, so a `/` in front of one — `a / b`,
* `reduce(/)` — is an operator whatever the parse says.
*/
const NON_REGEX_START = new Set([' ', '\t', '\n', ')']);
/** A `/` directly after one of these ends an operand, so it divides rather than opening a regex. */
const OPERAND_END = /[A-Za-z0-9_$)\]`"?!]$/;
/** The identifier a lookbehind ends on, when it ends on one. */
const TRAILING_IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Keywords a `/` can follow while still being at the start of an expression. Value keywords
* (`self`, `super`, `nil`, `true`, `false`) are operands and so are deliberately absent.
*/
const EXPRESSION_KEYWORDS = new Set([
'as',
'await',
'borrowing',
'case',
'catch',
'consume',
'consuming',
'copy',
'default',
'defer',
'do',
'each',
'else',
'for',
'guard',
'if',
'in',
'is',
'let',
'repeat',
'return',
'switch',
'throw',
'try',
'var',
'where',
'while',
'yield',
]);
/** How an unterminated frame is named in the error that refuses to ship the file. */
const FRAME_DESCRIPTIONS = {
literal: 'string literal',
regex: 'regex literal',
interpolation: 'interpolation',
};
/**
* `source` with its comments removed and its line numbering intact. A line whose only content
* was a comment is emitted empty rather than dropped, and a comment taken off the end of a code
* line takes no newline with it, so output line N is input line N for every N. Pre-existing blank
* lines, and every byte inside a literal, survive untouched.
*
* @param {string} source Swift source text.
* @param {string} filePath Reported in errors, so an unreadable construct names its file.
* @returns {{ contents: string, removedComments: number }}
*/
export function stripSwiftComments(source, filePath = '<swift source>') {
const state = {
source,
filePath,
index: 0,
sourceLine: 1,
/** Completed output lines, each still carrying its newline. */
lines: [],
/** The output line being built. */
line: '',
/** The tail of everything emitted so far, for the scanner's one lookbehind. */
codeTail: '',
lineHasComment: false,
/** Literal, regex-literal and interpolation nesting, innermost last. */
frames: [],
removedComments: 0,
};
while (state.index < source.length) {
const frame = state.frames.at(-1);
if (frame?.kind === 'literal') scanStringLiteralCharacter(state, frame);
else if (frame?.kind === 'regex') scanRegexLiteralCharacter(state, frame);
else scanCodeCharacter(state);
}
finishFile(state);
return { contents: state.lines.join(''), removedComments: state.removedComments };
}
/** The literal whose bytes are being copied through verbatim, if the scanner is inside one. */
function currentLiteral(state) {
const frame = state.frames.at(-1);
return frame !== undefined && (frame.kind === 'literal' || frame.kind === 'regex')
? frame
: undefined;
}
/** Appends to the output line, keeping the lookbehind tail in step with it. */
function emit(state, text) {
state.line += text;
state.codeTail = (state.codeTail + text).slice(-CODE_TAIL_LENGTH);
}
function scanCodeCharacter(state) {
const char = state.source[state.index];
if (char === '/' && consumeSlash(state)) {
return;
}
if (char === '\n') {
state.index += 1;
endLine(state);
return;
}
if ((char === '"' || char === '#') && pushLiteral(state)) {
return;
}
trackInterpolationParenthesis(state, char);
emit(state, char);
state.index += 1;
}
/**
* Resolves the `/` at the cursor: it opens a comment, or it is an operator, or — where the scanner
* cannot prove which — it fails the file. `false` leaves the `/` to be emitted as an operator.
*/
function consumeSlash(state) {
const next = state.source[state.index + 1];
if (next === '/') {
consumeLineComment(state);
return true;
}
if (next === '*') {
consumeBlockComment(state);
return true;
}
rejectAmbiguousBareRegexLiteral(state, next);
return false;
}
/**
* Opens a literal frame when the `"`/`#` at the cursor really starts one. `#` also leads every
* Swift directive (`#if`, `#available`, `#!` in the recording scripts), so only a `#`-run followed
* by a quote is a raw string literal, and only a `#`-run followed by `/` is an extended regex
* literal.
*/
function pushLiteral(state) {
return pushStringLiteral(state) || pushExtendedRegexLiteral(state);
}
function pushStringLiteral(state) {
STRING_OPENER.lastIndex = state.index;
const opener = STRING_OPENER.exec(state.source);
if (opener === null) return false;
const pounds = '#'.repeat(opener[1].length);
state.frames.push({
kind: 'literal',
multiline: opener[2] === '"""',
terminator: `${opener[2]}${pounds}`,
escape: `\\${pounds}`,
startLine: state.sourceLine,
});
emit(state, opener[0]);
state.index += opener[0].length;
return true;
}
/**
* Opens an extended regex literal (`#/…/#`, `##/…/##`). Its contents are regex syntax, where `//`
* and `/*` are ordinary characters, so the frame exists only to keep the comment scanner out. A
* newline straight after the opener selects Swift's multi-line form, whose closing delimiter has
* to stand on its own line — everywhere else `/` plus the `#` run is regex content.
*/
function pushExtendedRegexLiteral(state) {
EXTENDED_REGEX_OPENER.lastIndex = state.index;
const opener = EXTENDED_REGEX_OPENER.exec(state.source);
if (opener === null) return false;
state.frames.push({
kind: 'regex',
multiline: state.source[state.index + opener[0].length] === '\n',
terminator: `/${opener[1]}`,
startLine: state.sourceLine,
});
emit(state, opener[0]);
state.index += opener[0].length;
return true;
}
function scanRegexLiteralCharacter(state, regex) {
if (state.source.startsWith(regex.terminator, state.index)) {
closeRegexLiteral(state, regex);
return;
}
const char = state.source[state.index];
// A regex escape is copied as a pair, so `\/` never reads as the closing delimiter.
if (char === '\\' && isEscapableRegexCharacter(state.source[state.index + 1])) {
emit(state, state.source.slice(state.index, state.index + 2));
state.index += 2;
return;
}
if (char === '\n') {
if (!regex.multiline) {
throw new Error(`Unterminated regex literal in ${state.filePath}:${regex.startLine}`);
}
state.index += 1;
endLine(state);
return;
}
emit(state, char);
state.index += 1;
}
/**
* Closes the literal at its delimiter. Swift closes a multi-line regex literal at the first
* unescaped `/` plus its `#` run too, but then requires that delimiter to start its own line —
* so a mid-line one is a file that does not compile either way, and stripping it is refused
* rather than guessed at.
*/
function closeRegexLiteral(state, regex) {
if (regex.multiline && state.line.trim() !== '') {
throw new Error(
`Multi-line regex literal in ${state.filePath}:${regex.startLine} closes mid-line at ` +
`line ${state.sourceLine}; its ${regex.terminator} delimiter must start its own line`,
);
}
emit(state, regex.terminator);
state.index += regex.terminator.length;
state.frames.pop();
}
function isEscapableRegexCharacter(char) {
return char !== undefined && char !== '\n';
}
/**
* Refuses a `/` that could open a bare regex literal. Swift lexes `/…/`, a division and a comment
* from the same character, and only the parse separates them, so rewriting the bytes after it
* would be a guess: `let p = /foo//bar/` has no comment in it at all. Packaging fails instead.
*/
function rejectAmbiguousBareRegexLiteral(state, next) {
if (next === undefined || NON_REGEX_START.has(next)) return;
if (!isExpressionPosition(state)) return;
throw new Error(
`Ambiguous bare regex literal or division in ${state.filePath}:${state.sourceLine}; ` +
'write the pattern as an extended regex literal (#/…/#), or space the operator (a / b), ' +
'so packaging can tell them apart',
);
}
/**
* Whether an expression could start at the cursor, which is where — and only where — Swift reads
* a `/` as a bare regex literal. Anywhere else the `/` follows an operand and divides it.
*/
function isExpressionPosition(state) {
const tail = state.codeTail.replace(/\s+$/u, '');
if (tail === '') return true;
if (!OPERAND_END.test(tail)) return true;
// `return /x/` ends on an identifier yet still starts an expression.
const identifier = TRAILING_IDENTIFIER.exec(tail)?.[0];
return identifier !== undefined && EXPRESSION_KEYWORDS.has(identifier);
}
/** Closes an interpolation segment at its matching `)`, so its own parentheses do not end it. */
function trackInterpolationParenthesis(state, char) {
const frame = state.frames.at(-1);
if (frame === undefined || frame.kind !== 'interpolation') return;
if (char === '(') frame.depth += 1;
if (char !== ')') return;
frame.depth -= 1;
if (frame.depth === 0) state.frames.pop();
}
function scanStringLiteralCharacter(state, literal) {
if (state.source.startsWith(literal.terminator, state.index)) {
emit(state, literal.terminator);
state.index += literal.terminator.length;
state.frames.pop();
return;
}
if (state.source.startsWith(literal.escape, state.index) && consumeEscape(state, literal)) {
return;
}
const char = state.source[state.index];
if (char === '\n') {
consumeLiteralNewline(state, literal);
return;
}
emit(state, char);
state.index += 1;
}
/**
* Consumes one escape sequence and, for `\(`, enters its interpolation. Copying the escaped
* character verbatim is what keeps `\"` and `\\` from being read as a delimiter.
*/
function consumeEscape(state, literal) {
const escapedIndex = state.index + literal.escape.length;
const char = state.source[escapedIndex];
if (char === undefined) return false;
if (char === '\n') {
// A multiline literal's line continuation: the newline belongs to the literal, but the
// output still breaks its line here so line accounting stays on the source.
emit(state, literal.escape);
state.index = escapedIndex + 1;
endLine(state);
return true;
}
emit(state, state.source.slice(state.index, escapedIndex + 1));
state.index = escapedIndex + 1;
if (char === '(') state.frames.push({ kind: 'interpolation', depth: 1 });
return true;
}
function consumeLiteralNewline(state, literal) {
if (!literal.multiline) {
throw new Error(`Unterminated string literal in ${state.filePath}:${literal.startLine}`);
}
state.index += 1;
endLine(state);
}
function consumeLineComment(state) {
while (state.index < state.source.length && state.source[state.index] !== '\n') {
state.index += 1;
}
state.lineHasComment = true;
state.removedComments += 1;
}
function consumeBlockComment(state) {
const startLine = state.sourceLine;
state.index += 2;
let depth = 1;
while (depth > 0) {
if (state.index >= state.source.length) {
throw new Error(`Unterminated block comment in ${state.filePath}:${startLine}`);
}
depth += consumeBlockCommentCharacter(state);
}
state.lineHasComment = true;
// One space in place of the comment keeps the tokens that flanked it apart: Swift reads
// `a/*x*/b` as `a b`, not as `ab`.
emit(state, ' ');
state.removedComments += 1;
}
/** The nesting delta for one character of a block comment. */
function consumeBlockCommentCharacter(state) {
const char = state.source[state.index];
const next = state.source[state.index + 1];
if (char === '/' && next === '*') {
state.index += 2;
return 1;
}
if (char === '*' && next === '/') {
state.index += 2;
return -1;
}
state.index += 1;
if (char === '\n') {
// Both the line being closed and the line being opened are inside the comment, and
// `endLine` clears the flag between them.
state.lineHasComment = true;
endLine(state);
state.lineHasComment = true;
}
return 0;
}
/** Commits the line whose newline was just consumed, blank line and all. */
function endLine(state) {
state.sourceLine += 1;
state.lines.push(`${commitLine(state)}\n`);
state.line = '';
state.codeTail = (state.codeTail + '\n').slice(-CODE_TAIL_LENGTH);
state.lineHasComment = false;
}
/**
* The text one output line carries. A line inside a literal is committed verbatim: its trailing
* spaces and its emptiness are string content, not layout. A line a comment came off loses the
* whitespace the comment left behind — and nothing else, so a line that was only a comment
* commits as empty rather than disappearing.
*/
function commitLine(state) {
if (currentLiteral(state) !== undefined || !state.lineHasComment) return state.line;
return state.line.trimEnd();
}
/** The trailing line of a source that does not end in a newline, plus the balance check. */
function finishFile(state) {
const unterminated = state.frames.at(-1);
if (unterminated !== undefined) {
throw new Error(
`Unterminated ${FRAME_DESCRIPTIONS[unterminated.kind]} in ${state.filePath} ` +
`(started at line ${unterminated.startLine ?? state.sourceLine})`,
);
}
state.lines.push(commitLine(state));
}