refactor: add cross-platform utility functions

This commit is contained in:
Haileyesus
2026-03-17 10:19:50 +03:00
parent 23c39a42b1
commit 7df21556dd
16 changed files with 872 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
// This barrel keeps future imports short while the backend migrates into TypeScript.
export * from './path.js';
export * from './runtime-platform.js';
export * from './shell.js';
export * from './stream.js';
export * from './text.js';
export * from './types.js';

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { arePathsEquivalent, normalizePathForPlatform, toPortablePath } from './path.js';
// This test verifies path strings can be normalized for logs and platform-specific execution.
test('path helpers normalize separators in both directions', () => {
assert.equal(toPortablePath('folder\\child\\file.txt'), 'folder/child/file.txt');
assert.equal(
normalizePathForPlatform('folder\\child/file.txt', 'windows'),
'folder\\child\\file.txt',
);
assert.equal(
normalizePathForPlatform('folder\\child/file.txt', 'linux'),
'folder/child/file.txt',
);
});
// This test verifies path comparison respects Windows case-insensitivity but POSIX case-sensitivity.
test('arePathsEquivalent follows the case rules of the target platform', () => {
assert.equal(
arePathsEquivalent('C:\\Repo\\File.txt', 'c:/repo/file.txt', 'windows'),
true,
);
assert.equal(
arePathsEquivalent('/repo/File.txt', '/repo/file.txt', 'linux'),
false,
);
});

View File

@@ -0,0 +1,34 @@
import path from 'path';
import { getPlatformPathSeparator, isWindowsPlatform, resolveRuntimePlatform } from './runtime-platform.js';
import type { RuntimePlatform } from './types.js';
// This helper converts paths into a portable slash-separated form for logs, keys, and serialized payloads.
export function toPortablePath(value: string): string {
return value.replace(/\\/g, '/');
}
// This helper rewrites any mixture of separators into the preferred style for the target platform.
export function normalizePathForPlatform(
value: string,
platform: RuntimePlatform = resolveRuntimePlatform(),
): string {
const separator = getPlatformPathSeparator(platform);
return value.replace(/[\\/]+/g, separator);
}
// This helper compares paths using the case-sensitivity rules of the target platform.
export function arePathsEquivalent(
left: string,
right: string,
platform: RuntimePlatform = resolveRuntimePlatform(),
): boolean {
// This branch uses the target platform's path semantics instead of the host machine's semantics.
const pathModule = isWindowsPlatform(platform) ? path.win32 : path.posix;
const normalizedLeft = pathModule.normalize(normalizePathForPlatform(left, platform));
const normalizedRight = pathModule.normalize(normalizePathForPlatform(right, platform));
return isWindowsPlatform(platform)
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getPlatformLineEnding,
getPlatformPathSeparator,
isWindowsPlatform,
resolveRuntimePlatform,
} from './runtime-platform.js';
// This test covers the platform vocabulary used by the adapter layer.
test('resolveRuntimePlatform maps Node platforms into adapter platforms', () => {
assert.equal(resolveRuntimePlatform('win32'), 'windows');
assert.equal(resolveRuntimePlatform('darwin'), 'macos');
assert.equal(resolveRuntimePlatform('linux'), 'linux');
assert.equal(resolveRuntimePlatform('freebsd'), 'linux');
});
// This test verifies the shared helpers expose the expected OS defaults.
test('platform helpers expose the expected line endings and separators', () => {
assert.equal(isWindowsPlatform('windows'), true);
assert.equal(isWindowsPlatform('linux'), false);
assert.equal(getPlatformLineEnding('windows'), 'crlf');
assert.equal(getPlatformLineEnding('linux'), 'lf');
assert.equal(getPlatformPathSeparator('windows'), '\\');
assert.equal(getPlatformPathSeparator('macos'), '/');
});

View File

@@ -0,0 +1,29 @@
import type { LineEnding, RuntimePlatform } from './types.js';
// This function maps Node's platform strings into the smaller vocabulary used by the adapter layer.
export function resolveRuntimePlatform(nodePlatform: NodeJS.Platform = process.platform): RuntimePlatform {
switch (nodePlatform) {
case 'win32':
return 'windows';
case 'darwin':
return 'macos';
default:
// Every non-Windows, non-macOS platform in this project behaves like a POSIX shell target.
return 'linux';
}
}
// This helper keeps Windows checks readable at call sites.
export function isWindowsPlatform(platform: RuntimePlatform = resolveRuntimePlatform()): boolean {
return platform === 'windows';
}
// This helper centralizes the preferred newline style for each platform.
export function getPlatformLineEnding(platform: RuntimePlatform = resolveRuntimePlatform()): LineEnding {
return isWindowsPlatform(platform) ? 'crlf' : 'lf';
}
// This helper centralizes the preferred path separator for each platform.
export function getPlatformPathSeparator(platform: RuntimePlatform = resolveRuntimePlatform()): '\\' | '/' {
return isWindowsPlatform(platform) ? '\\' : '/';
}

View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildFallbackCommand, createShellSpawnPlan, quoteShellArgument } from './shell.js';
// This test verifies the backend can ask for a shell launch without branching on the OS at every call site.
test('createShellSpawnPlan returns the expected executable and argv per platform', () => {
assert.deepEqual(createShellSpawnPlan('echo hello', 'windows'), {
platform: 'windows',
executable: 'powershell.exe',
args: ['-Command', 'echo hello'],
commandFlag: '-Command',
preferredLineEnding: 'crlf',
pathSeparator: '\\',
});
assert.deepEqual(createShellSpawnPlan('echo hello', 'linux'), {
platform: 'linux',
executable: 'bash',
args: ['-c', 'echo hello'],
commandFlag: '-c',
preferredLineEnding: 'lf',
pathSeparator: '/',
});
});
// This test verifies shell quoting rules stay isolated inside the adapter layer.
test('quoteShellArgument escapes embedded single quotes correctly', () => {
assert.equal(quoteShellArgument("it's", 'windows'), "'it''s'");
assert.equal(quoteShellArgument("it's", 'linux'), `'it'"'"'s'`);
});
// This test verifies resume-or-fallback command composition stays platform-specific in one helper.
test('buildFallbackCommand emits PowerShell or POSIX fallback syntax', () => {
assert.equal(
buildFallbackCommand("codex resume '123'", 'codex', 'windows'),
"codex resume '123'; if ($LASTEXITCODE -ne 0) { codex }",
);
assert.equal(
buildFallbackCommand("codex resume '123'", 'codex', 'linux'),
"codex resume '123' || codex",
);
});

View File

@@ -0,0 +1,55 @@
import { getPlatformLineEnding, getPlatformPathSeparator, resolveRuntimePlatform } from './runtime-platform.js';
import type { RuntimePlatform, ShellSpawnPlan } from './types.js';
// This helper returns the shell executable and argv shape for the target platform.
export function createShellSpawnPlan(
command: string,
platform: RuntimePlatform = resolveRuntimePlatform(),
): ShellSpawnPlan {
if (platform === 'windows') {
return {
platform,
executable: 'powershell.exe',
args: ['-Command', command],
commandFlag: '-Command',
preferredLineEnding: getPlatformLineEnding(platform),
pathSeparator: getPlatformPathSeparator(platform),
};
}
return {
platform,
executable: 'bash',
args: ['-c', command],
commandFlag: '-c',
preferredLineEnding: getPlatformLineEnding(platform),
pathSeparator: getPlatformPathSeparator(platform),
};
}
// This helper quotes one argument so the caller does not need to remember shell-specific escaping rules.
export function quoteShellArgument(
value: string,
platform: RuntimePlatform = resolveRuntimePlatform(),
): string {
if (platform === 'windows') {
// PowerShell escapes a single quote inside a single-quoted string by doubling it.
return `'${value.replace(/'/g, "''")}'`;
}
// POSIX shells escape a single quote by closing the string, injecting an escaped quote, and reopening it.
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
// This helper builds the platform-specific "try primary, then fallback" shell expression.
export function buildFallbackCommand(
primaryCommand: string,
fallbackCommand: string,
platform: RuntimePlatform = resolveRuntimePlatform(),
): string {
if (platform === 'windows') {
return `${primaryCommand}; if ($LASTEXITCODE -ne 0) { ${fallbackCommand} }`;
}
return `${primaryCommand} || ${fallbackCommand}`;
}

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createStreamLineAccumulator } from './stream.js';
// This test verifies CRLF split across chunk boundaries does not create a fake empty line.
test('createStreamLineAccumulator handles CRLF across chunk boundaries', () => {
const accumulator = createStreamLineAccumulator();
assert.deepEqual(accumulator.push('first\r'), []);
assert.deepEqual(accumulator.push('\nsecond\r\nthird'), ['first', 'second']);
assert.deepEqual(accumulator.flush(), ['third']);
});
// This test verifies the first chunk can safely contain a UTF-8 BOM.
test('createStreamLineAccumulator strips a BOM from the first chunk only', () => {
const accumulator = createStreamLineAccumulator();
assert.deepEqual(accumulator.push(Buffer.from('\uFEFFalpha\nbeta')), ['alpha']);
assert.deepEqual(accumulator.flush(), ['beta']);
});
// This test verifies callers can intentionally drop empty lines when parsing command output.
test('createStreamLineAccumulator can discard empty lines', () => {
const accumulator = createStreamLineAccumulator({ preserveEmptyLines: false });
assert.deepEqual(accumulator.push('one\n\n'), ['one']);
assert.deepEqual(accumulator.push('two\r\n\r\nthree'), ['two']);
assert.deepEqual(accumulator.flush(), ['three']);
});
// This test verifies the parser can be reused for a second stream after reset.
test('createStreamLineAccumulator reset clears the internal buffer', () => {
const accumulator = createStreamLineAccumulator();
assert.deepEqual(accumulator.push('partial'), []);
assert.equal(accumulator.peek(), 'partial');
accumulator.reset();
assert.equal(accumulator.peek(), '');
assert.deepEqual(accumulator.push('done\n'), ['done']);
});

View File

@@ -0,0 +1,98 @@
import { stripUtf8Bom } from './text.js';
import type { StreamLineAccumulator, StreamLineAccumulatorOptions } from './types.js';
// This helper keeps the push logic focused on line extraction rather than Buffer/string branching.
function chunkToString(chunk: Buffer | string): string {
return Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
}
// This helper lets callers reuse the same cross-platform line parser for stdout, stderr, or file streams.
export function createStreamLineAccumulator(
options: StreamLineAccumulatorOptions = {},
): StreamLineAccumulator {
const { preserveEmptyLines = true } = options;
let buffer = '';
let isFirstChunk = true;
// This helper applies BOM stripping only once because a stream can only start once.
const normalizeIncomingChunk = (chunk: Buffer | string): string => {
const text = chunkToString(chunk);
if (!isFirstChunk) {
return text;
}
isFirstChunk = false;
return stripUtf8Bom(text);
};
// This helper enforces the caller's empty-line policy in one place.
const maybeAppendLine = (lines: string[], line: string): void => {
if (preserveEmptyLines || line.length > 0) {
lines.push(line);
}
};
return {
// This method extracts only complete lines and keeps an incomplete trailing fragment in memory.
push: (chunk: Buffer | string): string[] => {
buffer += normalizeIncomingChunk(chunk);
const lines: string[] = [];
let lineStartIndex = 0;
let cursor = 0;
while (cursor < buffer.length) {
const currentCharacter = buffer[cursor];
if (currentCharacter === '\n') {
maybeAppendLine(lines, buffer.slice(lineStartIndex, cursor));
cursor += 1;
lineStartIndex = cursor;
continue;
}
if (currentCharacter === '\r') {
// A trailing carriage return may be the first half of a CRLF sequence from the next chunk.
if (cursor === buffer.length - 1) {
break;
}
maybeAppendLine(lines, buffer.slice(lineStartIndex, cursor));
cursor += buffer[cursor + 1] === '\n' ? 2 : 1;
lineStartIndex = cursor;
continue;
}
cursor += 1;
}
buffer = buffer.slice(lineStartIndex);
return lines;
},
// This method flushes the final unterminated fragment when the stream closes.
flush: (): string[] => {
if (buffer === '') {
return [];
}
const trailingLine = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer;
buffer = '';
if (!preserveEmptyLines && trailingLine.length === 0) {
return [];
}
return [trailingLine];
},
// This method exposes the buffered partial fragment for diagnostics or advanced callers.
peek: (): string => buffer,
// This method resets the parser so a caller can reuse the same object for a new stream.
reset: (): void => {
buffer = '';
isFirstChunk = true;
},
};
}

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
detectLineEnding,
normalizeLineEndings,
normalizeTerminalInput,
normalizeTextForParsing,
preserveExistingLineEndings,
splitLines,
stripUtf8Bom,
} from './text.js';
// This test verifies the parser can consume mixed OS line endings as one stable LF format.
test('normalizeTextForParsing converts CRLF and CR into LF', () => {
assert.equal(normalizeTextForParsing('a\r\nb\rc\n'), 'a\nb\nc\n');
});
// This test verifies BOM stripping and explicit output line-ending control.
test('normalizeLineEndings strips a UTF-8 BOM and can emit CRLF', () => {
assert.equal(stripUtf8Bom('\uFEFFhello'), 'hello');
assert.equal(normalizeLineEndings('\uFEFFa\nb', 'crlf'), 'a\r\nb');
});
// This test verifies callers can opt into preserving or trimming empty lines explicitly.
test('splitLines supports empty-line preservation and trailing-line trimming', () => {
assert.deepEqual(splitLines('a\r\n\r\nb\r\n'), ['a', '', 'b', '']);
assert.deepEqual(
splitLines('a\r\n\r\nb\r\n', {
preserveEmptyLines: false,
trimTrailingEmptyLine: true,
}),
['a', 'b'],
);
});
// This test verifies file rewrites can preserve the line-ending style already present on disk.
test('preserveExistingLineEndings reuses the current file style', () => {
assert.equal(detectLineEnding('a\r\nb\r\n'), 'crlf');
assert.equal(preserveExistingLineEndings('x\ny', 'a\r\nb\r\n'), 'x\r\ny');
});
// This test verifies pasted terminal input is normalized into carriage returns for PTY writes.
test('normalizeTerminalInput converts mixed newlines into carriage returns', () => {
assert.equal(normalizeTerminalInput('one\r\ntwo\nthree\rfour'), 'one\rtwo\rthree\rfour');
});

View File

@@ -0,0 +1,55 @@
import type { LineEnding, SplitLinesOptions } from './types.js';
// This constant is the UTF-8 byte order mark represented as a JavaScript string.
const UTF8_BOM = '\uFEFF';
// This helper removes a UTF-8 BOM because it breaks parsers that expect plain text or JSON at byte zero.
export function stripUtf8Bom(value: string): string {
return value.startsWith(UTF8_BOM) ? value.slice(1) : value;
}
// This helper turns any mixture of CRLF, LF, or legacy CR endings into one explicit target format.
export function normalizeLineEndings(value: string, target: LineEnding = 'lf'): string {
const withoutBom = stripUtf8Bom(value);
const asLf = withoutBom.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return target === 'crlf' ? asLf.replace(/\n/g, '\r\n') : asLf;
}
// This helper infers the dominant file style so later writes can preserve the existing convention.
export function detectLineEnding(value: string): LineEnding {
return value.includes('\r\n') ? 'crlf' : 'lf';
}
// This helper splits text into logical lines after normalizing line endings first.
export function splitLines(value: string, options: SplitLinesOptions = {}): string[] {
const { preserveEmptyLines = true, trimTrailingEmptyLine = false } = options;
const normalized = normalizeLineEndings(value, 'lf');
const lines = normalized.split('\n');
const trimmedLines =
trimTrailingEmptyLine && lines.at(-1) === ''
? lines.slice(0, -1)
: lines;
return preserveEmptyLines ? trimmedLines : trimmedLines.filter((line) => line.length > 0);
}
// This helper gives parsers one stable newline format regardless of the source operating system.
export function normalizeTextForParsing(value: string): string {
return normalizeLineEndings(value, 'lf');
}
// This helper prepares text for file output when the caller wants to force a specific line-ending style.
export function normalizeTextForFileWrite(value: string, lineEnding: LineEnding): string {
return normalizeLineEndings(value, lineEnding);
}
// This helper keeps file rewrites stable by reusing the line-ending style already present on disk.
export function preserveExistingLineEndings(nextText: string, currentText: string): string {
return normalizeTextForFileWrite(nextText, detectLineEnding(currentText));
}
// This helper converts pasted or synthetic input into the carriage-return form PTYs expect for Enter.
export function normalizeTerminalInput(value: string): string {
return stripUtf8Bom(value).replace(/\r\n|\n|\r/g, '\r');
}

View File

@@ -0,0 +1,34 @@
// This type keeps the rest of the backend independent from Node's raw platform names.
export type RuntimePlatform = 'windows' | 'linux' | 'macos';
// This type makes line-ending intent explicit in parser and file-write code.
export type LineEnding = 'lf' | 'crlf';
// This type describes how to launch a shell without leaking OS-specific details.
export type ShellSpawnPlan = {
platform: RuntimePlatform;
executable: string;
args: string[];
commandFlag: '-Command' | '-c';
preferredLineEnding: LineEnding;
pathSeparator: '\\' | '/';
};
// This type configures how static text should be split into lines.
export type SplitLinesOptions = {
preserveEmptyLines?: boolean;
trimTrailingEmptyLine?: boolean;
};
// This type configures how streaming stdout and stderr chunks should be accumulated.
export type StreamLineAccumulatorOptions = {
preserveEmptyLines?: boolean;
};
// This type is the public contract for incremental line parsing from process streams.
export type StreamLineAccumulator = {
push: (chunk: Buffer | string) => string[];
flush: () => string[];
peek: () => string;
reset: () => void;
};