UNPKG

erosolar-cli

Version:

Unified AI agent framework for the command line - Multi-provider support with schema-driven tools, code intelligence, and transparent reasoning

528 lines 18.9 kB
import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; export function buildDiffSegments(previous, next) { const before = normalizeNewlines(previous); const after = normalizeNewlines(next); if (before === after) { return []; } const gitSegments = tryBuildWithGit(before, after); if (gitSegments) { return gitSegments; } return buildNaiveDiff(before, after); } /** * Fast in-memory diff algorithm - no git spawning, no temp files. * Uses efficient line-by-line comparison with context tracking. * ~10x faster than git-based diff for typical edits. */ export function buildDiffSegmentsFast(previous, next) { const before = normalizeNewlines(previous); const after = normalizeNewlines(next); if (before === after) { return []; } const oldLines = splitLines(before); const newLines = splitLines(after); const segments = []; // Limit output for very large files const MAX_DIFF_SEGMENTS = 200; const MAX_LINE_LENGTH = 500; // Use simple LCS-based approach optimized for typical code edits // Most edits are small, so we use a fast path for detecting changed regions let i = 0; let j = 0; while (i < oldLines.length || j < newLines.length) { if (segments.length >= MAX_DIFF_SEGMENTS) { const remaining = Math.max(oldLines.length - i, newLines.length - j); if (remaining > 0) { segments.push({ type: 'added', lineNumber: 0, content: `[Diff truncated - ${remaining} more lines not shown]` }); } break; } const oldLine = oldLines[i]; const newLine = newLines[j]; // Both lines exist and match - skip if (oldLine === newLine) { i++; j++; continue; } // Look ahead to find matching lines (handles insertions/deletions) const lookAhead = 10; let foundOld = -1; let foundNew = -1; // Check if current old line appears later in new (deletion followed by same content) for (let k = 1; k <= lookAhead && j + k < newLines.length; k++) { if (oldLine === newLines[j + k]) { foundNew = j + k; break; } } // Check if current new line appears later in old (insertion) for (let k = 1; k <= lookAhead && i + k < oldLines.length; k++) { if (newLine === oldLines[i + k]) { foundOld = i + k; break; } } // Insertion: new lines were added if (foundOld > 0 && (foundNew < 0 || foundNew > foundOld)) { while (j < newLines.length && newLines[j] !== oldLines[i]) { if (segments.length >= MAX_DIFF_SEGMENTS) break; const content = newLines[j] || ''; segments.push({ type: 'added', lineNumber: j + 1, content: content.length > MAX_LINE_LENGTH ? content.slice(0, MAX_LINE_LENGTH) + '...' : content }); j++; } continue; } // Deletion: old lines were removed if (foundNew > 0) { while (i < oldLines.length && oldLines[i] !== newLines[j]) { if (segments.length >= MAX_DIFF_SEGMENTS) break; const content = oldLines[i] || ''; segments.push({ type: 'removed', lineNumber: i + 1, content: content.length > MAX_LINE_LENGTH ? content.slice(0, MAX_LINE_LENGTH) + '...' : content }); i++; } continue; } // Modification: line changed if (typeof oldLine === 'string') { const content = oldLine.length > MAX_LINE_LENGTH ? oldLine.slice(0, MAX_LINE_LENGTH) + '...' : oldLine; segments.push({ type: 'removed', lineNumber: i + 1, content }); i++; } if (typeof newLine === 'string') { const content = newLine.length > MAX_LINE_LENGTH ? newLine.slice(0, MAX_LINE_LENGTH) + '...' : newLine; segments.push({ type: 'added', lineNumber: j + 1, content }); j++; } // Handle end of one array if (i >= oldLines.length && j < newLines.length) { j++; } else if (j >= newLines.length && i < oldLines.length) { i++; } } return segments; } // ANSI color codes for terminal output const ANSI_RESET = '\x1b[0m'; const ANSI_RED = '\x1b[31m'; const ANSI_GREEN = '\x1b[32m'; const ANSI_DIM = '\x1b[2m'; /** * Format diff lines with + and - prefixes for added/removed lines. * Supports context lines (unchanged lines around changes). */ export function formatDiffLines(diff, useColors = true) { if (!diff.length) { return []; } const width = Math.max(1, ...diff.map((entry) => Math.max(1, entry.lineNumber).toString().length)); return diff.map((entry) => { const lineNumber = Math.max(1, entry.lineNumber); const body = entry.content.length > 0 ? entry.content : '[empty line]'; const paddedNumber = lineNumber.toString().padStart(width, ' '); if (entry.type === 'added') { const prefix = '+'; if (useColors) { return `${ANSI_GREEN}${prefix} L${paddedNumber} | ${body}${ANSI_RESET}`; } return `${prefix} L${paddedNumber} | ${body}`; } else if (entry.type === 'removed') { const prefix = '-'; if (useColors) { return `${ANSI_RED}${prefix} L${paddedNumber} | ${body}${ANSI_RESET}`; } return `${prefix} L${paddedNumber} | ${body}`; } else { // context line if (useColors) { return `${ANSI_DIM} L${paddedNumber} | ${body}${ANSI_RESET}`; } return ` L${paddedNumber} | ${body}`; } }); } /** * Format diff in Claude Code style with proper indentation. * Shows line numbers in margin with +/- symbols for changes. */ export function formatDiffClaudeStyle(diff, useColors = true) { if (!diff.length) { return []; } const INDENT = ' '; // 6 spaces for line number column const width = Math.max(1, ...diff.map((entry) => Math.max(1, entry.lineNumber).toString().length)); return diff.map((entry) => { const lineNumber = Math.max(1, entry.lineNumber); const paddedNumber = lineNumber.toString().padStart(width, ' '); if (entry.type === 'added') { const prefix = ` ${paddedNumber} +`; const body = entry.content; if (useColors) { return `${ANSI_GREEN}${INDENT}${prefix} ${body}${ANSI_RESET}`; } return `${INDENT}${prefix} ${body}`; } else if (entry.type === 'removed') { const prefix = ` ${paddedNumber} -`; const body = entry.content; if (useColors) { return `${ANSI_RED}${INDENT}${prefix} ${body}${ANSI_RESET}`; } return `${INDENT}${prefix} ${body}`; } else { // context line const prefix = ` ${paddedNumber} `; const body = entry.content; if (useColors) { return `${ANSI_DIM}${INDENT}${prefix} ${body}${ANSI_RESET}`; } return `${INDENT}${prefix} ${body}`; } }); } function tryBuildWithGit(before, after) { let tempDir = null; try { tempDir = mkdtempSync(join(tmpdir(), 'erosolar-diff-')); const originalPath = join(tempDir, 'before.txt'); const updatedPath = join(tempDir, 'after.txt'); writeFileSync(originalPath, before, 'utf8'); writeFileSync(updatedPath, after, 'utf8'); const result = spawnSync('git', ['--no-pager', 'diff', '--no-index', '--unified=0', '--color=never', '--', originalPath, updatedPath], { encoding: 'utf8' }); if (result.error) { const code = result.error.code; if (code === 'ENOENT') { return null; } return null; } if (typeof result.status === 'number' && result.status > 1) { return null; } return parseUnifiedDiff(result.stdout); } catch { return null; } finally { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); } } } function parseUnifiedDiff(output) { if (!output.trim()) { return []; } const lines = output.split('\n'); const segments = []; let oldLine = 0; let newLine = 0; for (const rawLine of lines) { const line = rawLine.replace(/\r$/, ''); if (!line) { continue; } if (line.startsWith('@@')) { const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); if (match?.[1] && match?.[2]) { oldLine = parseInt(match[1], 10); newLine = parseInt(match[2], 10); } continue; } if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ') || line.startsWith('index ')) { continue; } if (line.startsWith('Binary ')) { continue; } if (line.startsWith('\\')) { continue; } if (line.startsWith('+')) { segments.push({ type: 'added', lineNumber: newLine, content: line.slice(1) }); newLine += 1; continue; } if (line.startsWith('-')) { segments.push({ type: 'removed', lineNumber: oldLine, content: line.slice(1) }); oldLine += 1; continue; } if (line.startsWith(' ')) { oldLine += 1; newLine += 1; continue; } } return segments; } function buildNaiveDiff(before, after) { const a = splitLines(before); const b = splitLines(after); const max = Math.max(a.length, b.length); const segments = []; // Limit diff output for very large files to prevent memory issues const MAX_DIFF_SEGMENTS = 500; const MAX_LINE_LENGTH = 1000; for (let index = 0; index < max; index += 1) { // Stop if we've collected too many segments if (segments.length >= MAX_DIFF_SEGMENTS) { segments.push({ type: 'added', lineNumber: 0, content: `[Diff truncated - ${max - index} more lines not shown]` }); break; } const left = a[index]; const right = b[index]; if (left === right) { continue; } if (typeof left === 'string') { // Truncate very long lines to prevent display issues const content = left.length > MAX_LINE_LENGTH ? left.slice(0, MAX_LINE_LENGTH) + '...' : left; segments.push({ type: 'removed', lineNumber: index + 1, content }); } if (typeof right === 'string') { // Truncate very long lines to prevent display issues const content = right.length > MAX_LINE_LENGTH ? right.slice(0, MAX_LINE_LENGTH) + '...' : right; segments.push({ type: 'added', lineNumber: index + 1, content }); } } return segments; } function normalizeNewlines(value) { return value.replace(/\r\n/g, '\n'); } function splitLines(value) { if (!value) { return []; } const normalized = normalizeNewlines(value); return normalized.split('\n'); } /** * Build a diff with context lines around changes (Claude Code style). * Shows N lines before and after each change, with ... truncation for gaps. */ export function buildDiffWithContext(previous, next, contextLines = 2) { const before = normalizeNewlines(previous); const after = normalizeNewlines(next); if (before === after) { return { segments: [], additions: 0, removals: 0 }; } const oldLines = splitLines(before); const newLines = splitLines(after); // First, identify all changed line indices const changes = []; // Simple LCS to find changes let i = 0; let j = 0; while (i < oldLines.length || j < newLines.length) { const oldLine = oldLines[i]; const newLine = newLines[j]; if (oldLine === newLine) { i++; j++; continue; } // Look ahead for matching lines const lookAhead = 15; let foundInNew = -1; let foundInOld = -1; for (let k = 1; k <= lookAhead && j + k < newLines.length; k++) { if (oldLine === newLines[j + k]) { foundInNew = j + k; break; } } for (let k = 1; k <= lookAhead && i + k < oldLines.length; k++) { if (newLine === oldLines[i + k]) { foundInOld = i + k; break; } } // Insertion if (foundInOld > 0 && (foundInNew < 0 || foundInNew > foundInOld)) { while (j < newLines.length && newLines[j] !== oldLines[i]) { changes.push({ type: 'added', lineNumber: j + 1, content: newLines[j] || '', newLineIndex: j, }); j++; } continue; } // Deletion if (foundInNew > 0) { while (i < oldLines.length && oldLines[i] !== newLines[j]) { changes.push({ type: 'removed', lineNumber: i + 1, content: oldLines[i] || '', oldLineIndex: i, }); i++; } continue; } // Modification if (typeof oldLine === 'string') { changes.push({ type: 'removed', lineNumber: i + 1, content: oldLine, oldLineIndex: i, }); i++; } if (typeof newLine === 'string') { changes.push({ type: 'added', lineNumber: j + 1, content: newLine, newLineIndex: j, }); j++; } if (i >= oldLines.length && j < newLines.length) { j++; } else if (j >= newLines.length && i < oldLines.length) { i++; } } // Count additions and removals const additions = changes.filter((c) => c.type === 'added').length; const removals = changes.filter((c) => c.type === 'removed').length; // Now build segments with context // Group changes that are close together const segments = []; const changeIndices = new Set(); const removedIndices = new Set(); for (const change of changes) { if (change.type === 'added' && change.newLineIndex !== undefined) { changeIndices.add(change.newLineIndex); } if (change.type === 'removed' && change.oldLineIndex !== undefined) { removedIndices.add(change.oldLineIndex); } } // For each change, include context lines const linesToShow = new Set(); for (const change of changes) { if (change.type === 'added' && change.newLineIndex !== undefined) { const idx = change.newLineIndex; for (let k = Math.max(0, idx - contextLines); k <= Math.min(newLines.length - 1, idx + contextLines); k++) { linesToShow.add(k); } } } // Build final segments in order let lastLineShown = -1; const sortedLines = Array.from(linesToShow).sort((a, b) => a - b); for (const lineIdx of sortedLines) { // Add truncation marker if there's a gap if (lastLineShown >= 0 && lineIdx > lastLineShown + 1) { segments.push({ type: 'context', lineNumber: 0, content: '...', }); } if (changeIndices.has(lineIdx)) { segments.push({ type: 'added', lineNumber: lineIdx + 1, content: newLines[lineIdx] || '', }); } else { segments.push({ type: 'context', lineNumber: lineIdx + 1, content: newLines[lineIdx] || '', }); } lastLineShown = lineIdx; } // Add removed lines (show them before the context around their location) // Rebuild segments to interleave removals properly const finalSegments = []; let changeIdx = 0; for (const seg of segments) { // Insert any removals that come before this line while (changeIdx < changes.length) { const change = changes[changeIdx]; if (!change) break; if (change.type === 'removed') { // Find where this removal should go - before the added line at same position const removedOldIdx = change.oldLineIndex ?? 0; // If we're showing an added line that replaced this removed line const matchingAdd = changes.find((c) => c.type === 'added' && c.newLineIndex !== undefined && Math.abs((c.newLineIndex) - removedOldIdx) <= 1); if (matchingAdd && seg.type === 'added' && seg.lineNumber === (matchingAdd.newLineIndex ?? 0) + 1) { finalSegments.push({ type: 'removed', lineNumber: change.lineNumber, content: change.content, }); changeIdx++; continue; } } break; } finalSegments.push(seg); } // Add any remaining removals at the end while (changeIdx < changes.length) { const change = changes[changeIdx]; if (!change) break; if (change.type === 'removed') { finalSegments.push({ type: 'removed', lineNumber: change.lineNumber, content: change.content, }); } changeIdx++; } return { segments: finalSegments.length > 0 ? finalSegments : changes, additions, removals }; } //# sourceMappingURL=diffUtils.js.map