branch:
helpers.ts
15786 bytesRaw
import type {
  StateAppliedEditResult,
  StateApplyEditsResult,
  StateApplyEditsOptions,
  StateDirent,
  StateEditInstruction,
  StateEditPlan,
  StateEntryType,
  StateFileReplaceResult,
  StateFileSearchResult,
  StatePlannedEdit,
  StateReplaceInFilesResult,
  StateReplaceInFilesOptions,
  StateReplaceResult,
  StateSearchOptions,
  StateStat,
  StateTextMatch
} from "./backend";
import { StateBatchOperationError } from "./backend";

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const MAX_DIFF_LINES = 10_000;

export function encodeText(value: string): Uint8Array {
  return textEncoder.encode(value);
}

export function decodeText(value: Uint8Array): string {
  return textDecoder.decode(value);
}

export function stateDirent(name: string, type: StateEntryType): StateDirent {
  return { name, type };
}

export function diffContent(
  current: string,
  next: string,
  labelA: string,
  labelB: string
): string {
  const linesA = current.split("\n").length;
  const linesB = next.split("\n").length;
  if (linesA > MAX_DIFF_LINES || linesB > MAX_DIFF_LINES) {
    throw new Error(
      `EFBIG: content too large for diff (max ${MAX_DIFF_LINES} lines)`
    );
  }

  return unifiedDiff(current, next, labelA, labelB);
}

export function createGlobMatcher(pattern: string): RegExp {
  let i = 0;
  let re = "^";
  while (i < pattern.length) {
    const ch = pattern[i];
    if (ch === "*") {
      if (pattern[i + 1] === "*") {
        i += 2;
        if (pattern[i] === "/") {
          re += "(?:.+/)?";
          i++;
        } else {
          re += ".*";
        }
      } else {
        re += "[^/]*";
        i++;
      }
    } else if (ch === "?") {
      re += "[^/]";
      i++;
    } else if (ch === "[") {
      const close = pattern.indexOf("]", i + 1);
      if (close === -1) {
        re += "\\[";
        i++;
      } else {
        re += pattern.slice(i, close + 1);
        i = close + 1;
      }
    } else if (ch === "{") {
      const close = pattern.indexOf("}", i + 1);
      if (close === -1) {
        re += "\\{";
        i++;
      } else {
        const inner = pattern
          .slice(i + 1, close)
          .split(",")
          .join("|");
        re += `(?:${inner})`;
        i = close + 1;
      }
    } else {
      re += ch.replace(/[.+^$|\\()]/g, "\\$&");
      i++;
    }
  }
  re += "$";
  return new RegExp(re);
}

export function sortPaths(paths: string[]): string[] {
  return [...paths].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}

export function toStateStat(input: {
  type: StateEntryType;
  size: number;
  mtime: Date;
  mode?: number;
}): StateStat {
  return {
    type: input.type,
    size: input.size,
    mtime: input.mtime,
    mode: input.mode
  };
}

export function parseJsonFileContent(content: string, path: string): unknown {
  try {
    return JSON.parse(content) as unknown;
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(`Invalid JSON in ${path}: ${message}`);
  }
}

export function stringifyJsonFileContent(
  value: unknown,
  path: string,
  spaces = 2
): string {
  const serialized = JSON.stringify(value, null, spaces);
  if (serialized === undefined) {
    throw new Error(`Unable to serialize JSON for ${path}`);
  }
  return serialized + "\n";
}

export function searchTextContent(
  content: string,
  query: string,
  options: StateSearchOptions = {}
): StateTextMatch[] {
  const matcher = createTextMatcher(query, options);
  const matches: StateTextMatch[] = [];
  const lines = content.split("\n");

  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
    const lineText = lines[lineIndex];
    matcher.lastIndex = 0;

    for (;;) {
      const match = matcher.exec(lineText);
      if (!match) {
        break;
      }

      matches.push({
        line: lineIndex + 1,
        column: match.index + 1,
        match: match[0],
        lineText,
        ...(options.contextBefore
          ? {
              beforeLines: lines.slice(
                Math.max(0, lineIndex - options.contextBefore),
                lineIndex
              )
            }
          : {}),
        ...(options.contextAfter
          ? {
              afterLines: lines.slice(
                lineIndex + 1,
                lineIndex + 1 + options.contextAfter
              )
            }
          : {})
      });

      if (
        options.maxMatches !== undefined &&
        matches.length >= options.maxMatches
      ) {
        return matches;
      }

      if (match[0].length === 0) {
        matcher.lastIndex++;
      }
    }
  }

  return matches;
}

export function replaceTextContent(
  content: string,
  search: string,
  replacement: string,
  options: StateSearchOptions = {}
): StateReplaceResult {
  const matcher = createTextMatcher(search, options);
  let replaced = 0;
  const nextContent = content.replace(matcher, () => {
    replaced++;
    return replacement;
  });

  return {
    replaced,
    content: nextContent
  };
}

export async function collectFileSearchResults(
  paths: string[],
  readFile: (path: string) => Promise<string>,
  search: (content: string) => StateTextMatch[]
): Promise<StateFileSearchResult[]> {
  const results: StateFileSearchResult[] = [];

  for (const path of paths) {
    const content = await readFile(path);
    const matches = search(content);
    if (matches.length > 0) {
      results.push({ path, matches });
    }
  }

  return results;
}

export async function collectFileReplaceResults(
  paths: string[],
  readFile: (path: string) => Promise<string>,
  writeFile: (path: string, content: string) => Promise<void>,
  deleteFile: (path: string) => Promise<void>,
  search: string,
  replacement: string,
  options: StateReplaceInFilesOptions = {}
): Promise<StateReplaceInFilesResult> {
  const files: StateFileReplaceResult[] = [];
  let totalReplacements = 0;
  const appliedSnapshots: Array<{ path: string; previous: string | null }> = [];

  try {
    for (const path of paths) {
      const current = await readFile(path);
      const replaced = replaceTextContent(
        current,
        search,
        replacement,
        options
      );
      if (replaced.replaced === 0) {
        continue;
      }

      if (!options.dryRun) {
        await writeFile(path, replaced.content);
        appliedSnapshots.push({ path, previous: current });
      }

      files.push({
        path,
        replaced: replaced.replaced,
        content: replaced.content,
        diff: diffContent(current, replaced.content, path, path)
      });
      totalReplacements += replaced.replaced;
    }
  } catch (error) {
    const rollback = options.rollbackOnError ?? true;
    const rollbackError = rollback
      ? await rollbackSnapshots(appliedSnapshots, writeFile, deleteFile)
      : undefined;
    throw new StateBatchOperationError({
      operation: "replaceInFiles",
      message: error instanceof Error ? error.message : String(error),
      rolledBack: rollback,
      rollbackError
    });
  }

  return {
    dryRun: options.dryRun ?? false,
    files,
    totalFiles: files.length,
    totalReplacements
  };
}

export async function applyTextEdits(
  edits: { path: string; content: string }[],
  readFileIfExists: (path: string) => Promise<string | null>,
  writeFile: (path: string, content: string) => Promise<void>,
  deleteFile: (path: string) => Promise<void>,
  options: StateApplyEditsOptions = {}
): Promise<StateApplyEditsResult> {
  const results: StateAppliedEditResult[] = [];
  let totalChanged = 0;
  const appliedSnapshots: Array<{ path: string; previous: string | null }> = [];

  try {
    for (const edit of edits) {
      const previous = await readFileIfExists(edit.path);
      const nextContent = edit.content;
      const changed = previous !== nextContent;

      if (changed && !options.dryRun) {
        await writeFile(edit.path, nextContent);
        appliedSnapshots.push({ path: edit.path, previous });
      }

      results.push({
        path: edit.path,
        changed,
        content: nextContent,
        diff: changed
          ? diffContent(previous ?? "", nextContent, edit.path, edit.path)
          : ""
      });

      if (changed) {
        totalChanged++;
      }
    }
  } catch (error) {
    const rollback = options.rollbackOnError ?? true;
    const rollbackError = rollback
      ? await rollbackSnapshots(appliedSnapshots, writeFile, deleteFile)
      : undefined;
    throw new StateBatchOperationError({
      operation: "applyEdits",
      message: error instanceof Error ? error.message : String(error),
      rolledBack: rollback,
      rollbackError
    });
  }

  return {
    dryRun: options.dryRun ?? false,
    edits: results,
    totalChanged
  };
}

export async function planTextEdits(
  instructions: StateEditInstruction[],
  readFileIfExists: (path: string) => Promise<string | null>
): Promise<StateEditPlan> {
  const edits: StatePlannedEdit[] = [];
  let totalChanged = 0;

  for (const instruction of instructions) {
    const previous = await readFileIfExists(instruction.path);
    const content = await plannedContentForInstruction(instruction, previous);
    const changed = previous !== content;

    edits.push({
      instruction,
      path: instruction.path,
      changed,
      content,
      diff: changed
        ? diffContent(
            previous ?? "",
            content,
            instruction.path,
            instruction.path
          )
        : ""
    });

    if (changed) {
      totalChanged++;
    }
  }

  return {
    edits,
    totalChanged,
    totalInstructions: instructions.length
  };
}

export function planToStateEdits(plan: StateEditPlan): Array<{
  path: string;
  content: string;
}> {
  return plan.edits.map((edit) => ({
    path: edit.path,
    content: edit.content
  }));
}

async function rollbackSnapshots(
  snapshots: Array<{ path: string; previous: string | null }>,
  writeFile: (path: string, content: string) => Promise<void>,
  deleteFile: (path: string) => Promise<void>
): Promise<string | undefined> {
  let rollbackError: string | undefined;
  for (let index = snapshots.length - 1; index >= 0; index--) {
    const snapshot = snapshots[index];
    try {
      if (snapshot.previous === null) {
        await deleteFile(snapshot.path);
      } else {
        await writeFile(snapshot.path, snapshot.previous);
      }
    } catch (error) {
      rollbackError = error instanceof Error ? error.message : String(error);
      break;
    }
  }
  return rollbackError;
}

async function plannedContentForInstruction(
  instruction: StateEditInstruction,
  previous: string | null
): Promise<string> {
  if (instruction.kind === "write") {
    return instruction.content;
  }

  if (instruction.kind === "writeJson") {
    return stringifyJsonFileContent(
      instruction.value,
      instruction.path,
      instruction.options?.spaces
    );
  }

  if (previous === null) {
    throw new Error(`ENOENT: no such file: ${instruction.path}`);
  }

  return replaceTextContent(
    previous,
    instruction.search,
    instruction.replacement,
    instruction.options
  ).content;
}

function createTextMatcher(query: string, options: StateSearchOptions): RegExp {
  if (query.length === 0) {
    throw new Error("Search query must not be empty");
  }

  let source = options.regex ? query : escapeRegExp(query);
  if (options.wholeWord) {
    source = `\\b(?:${source})\\b`;
  }

  try {
    return new RegExp(source, options.caseSensitive === false ? "gi" : "g");
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(`Invalid search pattern: ${message}`);
  }
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function unifiedDiff(
  before: string,
  after: string,
  labelBefore: string,
  labelAfter: string,
  contextLines = 3
): string {
  if (before === after) {
    return "";
  }

  const linesBefore = before.split("\n");
  const linesAfter = after.split("\n");
  const edits = myersDiff(linesBefore, linesAfter);
  return formatUnified(
    edits,
    linesBefore,
    linesAfter,
    labelBefore,
    labelAfter,
    contextLines
  );
}

type Edit = {
  type: "keep" | "delete" | "insert";
  lineA: number;
  lineB: number;
};

function myersDiff(before: string[], after: string[]): Edit[] {
  const n = before.length;
  const m = after.length;
  const max = n + m;
  const offset = max;
  const vector = new Int32Array(2 * max + 1);
  vector.fill(-1);
  vector[offset + 1] = 0;

  const trace: Int32Array[] = [];

  outer: for (let d = 0; d <= max; d++) {
    trace.push(vector.slice());
    for (let k = -d; k <= d; k += 2) {
      let x: number;
      if (
        k === -d ||
        (k !== d && vector[offset + k - 1] < vector[offset + k + 1])
      ) {
        x = vector[offset + k + 1];
      } else {
        x = vector[offset + k - 1] + 1;
      }

      let y = x - k;
      while (x < n && y < m && before[x] === after[y]) {
        x++;
        y++;
      }

      vector[offset + k] = x;
      if (x >= n && y >= m) {
        break outer;
      }
    }
  }

  const edits: Edit[] = [];
  let x = n;
  let y = m;

  for (let d = trace.length - 1; d >= 0; d--) {
    const previous = trace[d];
    const k = x - y;
    let previousK: number;
    if (
      k === -d ||
      (k !== d && previous[offset + k - 1] < previous[offset + k + 1])
    ) {
      previousK = k + 1;
    } else {
      previousK = k - 1;
    }

    const previousX = previous[offset + previousK];
    const previousY = previousX - previousK;

    while (x > previousX && y > previousY) {
      x--;
      y--;
      edits.push({ type: "keep", lineA: x, lineB: y });
    }

    if (d > 0) {
      if (x === previousX) {
        edits.push({ type: "insert", lineA: x, lineB: y - 1 });
        y--;
      } else {
        edits.push({ type: "delete", lineA: x - 1, lineB: y });
        x--;
      }
    }
  }

  edits.reverse();
  return edits;
}

function formatUnified(
  edits: Edit[],
  before: string[],
  after: string[],
  labelBefore: string,
  labelAfter: string,
  contextLines: number
): string {
  const output: string[] = [`--- ${labelBefore}`, `+++ ${labelAfter}`];
  const changes: number[] = [];
  for (let index = 0; index < edits.length; index++) {
    if (edits[index].type !== "keep") {
      changes.push(index);
    }
  }

  if (changes.length === 0) {
    return "";
  }

  let changeIndex = 0;
  while (changeIndex < changes.length) {
    let start = Math.max(0, changes[changeIndex] - contextLines);
    let end = Math.min(edits.length - 1, changes[changeIndex] + contextLines);

    let nextChange = changeIndex + 1;
    while (
      nextChange < changes.length &&
      changes[nextChange] - contextLines <= end + 1
    ) {
      end = Math.min(edits.length - 1, changes[nextChange] + contextLines);
      nextChange++;
    }

    const hunkLines: string[] = [];
    let countBefore = 0;
    let countAfter = 0;
    const startBefore = edits[start].lineA;
    const startAfter = edits[start].lineB;

    for (let idx = start; idx <= end; idx++) {
      const edit = edits[idx];
      if (edit.type === "keep") {
        hunkLines.push(` ${before[edit.lineA]}`);
        countBefore++;
        countAfter++;
      } else if (edit.type === "delete") {
        hunkLines.push(`-${before[edit.lineA]}`);
        countBefore++;
      } else {
        hunkLines.push(`+${after[edit.lineB]}`);
        countAfter++;
      }
    }

    output.push(
      `@@ -${startBefore + 1},${countBefore} +${startAfter + 1},${countAfter} @@`
    );
    output.push(...hunkLines);
    changeIndex = nextChange;
  }

  return output.join("\n");
}