diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 21d829266..8daef5ebd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,16 @@ ## [Unreleased] ### Added +- Added `edit.manageImports` setting to enable/disable post-edit import and include management +- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations +- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes +- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes +- Added comprehensive import handler modules for each supported language with deduplication and grouping logic +- Added `edit.manageImports` setting to enable/disable post-edit import and include management +- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations +- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes +- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes +- Added comprehensive import handler modules for each supported language with deduplication and grouping logic - Added `onExternalEditor` callback to extension UI dialog options for handling external editor shortcut in select dialogs - Added external editor shortcut support in plan review selector, allowing users to open and edit the plan in their configured editor - Added `matchesAppExternalEditor` keybinding matcher to detect external editor shortcut (Ctrl+G or configured binding) @@ -13,6 +23,9 @@ ### Changed +- Updated edit tool prompts to document import management feature when enabled +- Modified edit tool schemas to conditionally include `imports` parameter based on `edit.manageImports` setting +- Enhanced patch, hashline, and replace tool documentation with import management examples and guidance - Updated hook editor and hook selector components to use `matchesAppExternalEditor` matcher for consistent external editor keybinding detection - Modified plan review flow to read the latest plan content from disk before approval, allowing changes made in external editor to be reflected - Enhanced plan review help text to dynamically display the configured external editor keybinding diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 019063bba..32a8a1406 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -958,6 +958,16 @@ export const SETTINGS_SCHEMA = { }, }, + "edit.manageImports": { + type: "boolean", + default: false, + ui: { + tab: "editing", + label: "Manage Imports After Edit", + description: "Expose post-edit import/include management for the edit tool", + }, + }, + readLineNumbers: { type: "boolean", default: false, diff --git a/packages/coding-agent/src/patch/imports/cpp.ts b/packages/coding-agent/src/patch/imports/cpp.ts new file mode 100644 index 000000000..ff730d216 --- /dev/null +++ b/packages/coding-agent/src/patch/imports/cpp.ts @@ -0,0 +1,470 @@ +import type { ImportSpec } from "../types"; +import { + defaultImportStyle, + type ImportHandler, + type ImportRegion, + type ImportStyle, + type ParsedImport, +} from "./types"; + +type IncludeGroup = "system" | "local"; +type LineKind = "blank" | "comment" | "include" | "other"; + +interface ParsedIncludeEntry extends ParsedImport { + lineIndex: number; + group: IncludeGroup; +} + +interface LineClassification { + kind: LineKind; + inBlockComment: boolean; + parsedImport?: ParsedImport; +} + +interface IncludeRegionAnalysis extends ImportRegion { + entries: ParsedIncludeEntry[]; + groupOrder: IncludeGroup[]; +} + +const INCLUDE_PATTERN = /^#\s*include\s*([<"])([^>"]+)[>"](?:\s*(?:\/\/.*|\/\*.*\*\/\s*)?)?$/; +const PRAGMA_ONCE_PATTERN = /^#\s*pragma\s+once\b/; +const IFNDEF_PATTERN = /^#\s*ifndef\s+([A-Za-z_]\w*)\b/; + +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +function lastItem(items: T[]): T | undefined { + return items[items.length - 1]; +} + +function parseIncludeLine(text: string): ParsedImport | null { + const match = INCLUDE_PATTERN.exec(text.trim()); + if (!match) { + return null; + } + + const delimiter = match[1]; + const source = match[2]?.trim(); + if (!source) { + return null; + } + + return { + raw: text, + source, + names: [], + system: delimiter === "<", + }; +} + +function classifyLine(line: string, inBlockComment: boolean): LineClassification { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return { kind: "blank", inBlockComment }; + } + + if (inBlockComment) { + const blockEnd = trimmed.indexOf("*/"); + if (blockEnd === -1) { + return { kind: "comment", inBlockComment: true }; + } + + const remainder = trimmed.slice(blockEnd + 2).trim(); + if (remainder.length === 0) { + return { kind: "comment", inBlockComment: false }; + } + + return classifyLine(remainder, false); + } + + const parsedImport = parseIncludeLine(trimmed); + if (parsedImport) { + return { kind: "include", inBlockComment: false, parsedImport }; + } + + if (trimmed.startsWith("//")) { + return { kind: "comment", inBlockComment: false }; + } + + if (trimmed.startsWith("/*")) { + const blockEnd = trimmed.indexOf("*/", 2); + if (blockEnd === -1) { + return { kind: "comment", inBlockComment: true }; + } + + const remainder = trimmed.slice(blockEnd + 2).trim(); + if (remainder.length === 0) { + return { kind: "comment", inBlockComment: false }; + } + + return classifyLine(remainder, false); + } + + return { kind: "other", inBlockComment: false }; +} + +function findNextSubstantiveLine(lines: string[], startIndex: number): number | null { + let inBlockComment = false; + for (let index = startIndex; index < lines.length; index += 1) { + const classification = classifyLine(lines[index] ?? "", inBlockComment); + inBlockComment = classification.inBlockComment; + if (classification.kind === "blank" || classification.kind === "comment") { + continue; + } + return index; + } + return null; +} + +function findInsertionPoint(lines: string[]): number { + let index = 0; + if ((lines[0] ?? "").startsWith("#!")) { + index = 1; + } + + let inBlockComment = false; + while (index < lines.length) { + const classification = classifyLine(lines[index] ?? "", inBlockComment); + inBlockComment = classification.inBlockComment; + if (classification.kind === "blank" || classification.kind === "comment") { + index += 1; + continue; + } + break; + } + + const firstSubstantive = lines[index]?.trim() ?? ""; + if (PRAGMA_ONCE_PATTERN.test(firstSubstantive)) { + return index + 1; + } + + const guardMatch = IFNDEF_PATTERN.exec(firstSubstantive); + if (!guardMatch) { + return index; + } + + const macroName = guardMatch[1]; + const defineIndex = findNextSubstantiveLine(lines, index + 1); + if (defineIndex === null) { + return index; + } + + const definePattern = new RegExp(`^#\\s*define\\s+${macroName}\\b`); + return definePattern.test(lines[defineIndex]?.trim() ?? "") ? defineIndex + 1 : index; +} + +function detectGroupOrder(entries: ParsedIncludeEntry[]): IncludeGroup[] { + const firstSystem = entries.find(entry => entry.group === "system"); + const firstLocal = entries.find(entry => entry.group === "local"); + if (firstSystem && firstLocal) { + return firstSystem.lineIndex < firstLocal.lineIndex ? ["system", "local"] : ["local", "system"]; + } + return ["system", "local"]; +} + +function hasBlankBetweenGroups(lines: string[], entries: ParsedIncludeEntry[]): boolean { + const firstSystem = entries.find(entry => entry.group === "system"); + const firstLocal = entries.find(entry => entry.group === "local"); + if (!firstSystem || !firstLocal) { + return defaultImportStyle.groupSeparator; + } + + const start = Math.min(firstSystem.lineIndex, firstLocal.lineIndex); + const end = Math.max(firstSystem.lineIndex, firstLocal.lineIndex); + for (let index = start + 1; index < end; index += 1) { + if (isBlankLine(lines[index] ?? "")) { + return true; + } + } + return false; +} + +function isGroupSorted(entries: ParsedIncludeEntry[]): boolean { + for (let index = 1; index < entries.length; index += 1) { + if ((entries[index - 1]?.source ?? "").localeCompare(entries[index]?.source ?? "") > 0) { + return false; + } + } + return true; +} + +function analyzeIncludeRegion(content: string): IncludeRegionAnalysis | null { + const lines = content.split("\n"); + const insertionPoint = findInsertionPoint(lines); + + let index = insertionPoint; + let inBlockComment = false; + while (index < lines.length) { + const classification = classifyLine(lines[index] ?? "", inBlockComment); + inBlockComment = classification.inBlockComment; + if (classification.kind === "blank" || classification.kind === "comment") { + index += 1; + continue; + } + if (classification.kind !== "include") { + return null; + } + break; + } + + if (index >= lines.length) { + return null; + } + + const entries: ParsedIncludeEntry[] = []; + let endIndex = index; + inBlockComment = false; + + for (let lineIndex = index; lineIndex < lines.length; lineIndex += 1) { + const classification = classifyLine(lines[lineIndex] ?? "", inBlockComment); + inBlockComment = classification.inBlockComment; + if (classification.kind === "other") { + break; + } + + endIndex = lineIndex; + if (classification.kind !== "include" || !classification.parsedImport) { + continue; + } + + entries.push({ + ...classification.parsedImport, + group: classification.parsedImport.system ? "system" : "local", + lineIndex, + }); + } + + if (entries.length === 0) { + return null; + } + + const groupOrder = detectGroupOrder(entries); + const imports = entries.map(({ lineIndex, group, ...parsedImport }) => parsedImport); + const systemEntries = entries.filter(entry => entry.group === "system"); + const localEntries = entries.filter(entry => entry.group === "local"); + + return { + startLine: index + 1, + endLine: endIndex + 1, + imports, + entries, + groupOrder, + style: { + ...defaultImportStyle, + groupSeparator: hasBlankBetweenGroups(lines, entries), + groupOrder, + sorted: isGroupSorted(systemEntries) && isGroupSorted(localEntries), + }, + }; +} + +function parseExisting(content: string): ImportRegion | null { + const region = analyzeIncludeRegion(content); + if (!region) { + return null; + } + + return { + startLine: region.startLine, + endLine: region.endLine, + imports: region.imports, + style: region.style, + }; +} + +function specToLine(spec: ImportSpec, _style: ImportStyle): string { + return spec.system ? `#include <${spec.from}>` : `#include "${spec.from}"`; +} + +function makeSpecKey(spec: Pick): string { + return `${spec.system ? "system" : "local"}:${spec.from}`; +} + +function dedupeSpecs(specs: ImportSpec[], existing: Iterable>): ImportSpec[] { + const seen = new Set(); + for (const spec of existing) { + seen.add(makeSpecKey(spec)); + } + + const uniqueSpecs: ImportSpec[] = []; + for (const spec of specs) { + const key = makeSpecKey(spec); + if (seen.has(key)) { + continue; + } + seen.add(key); + uniqueSpecs.push(spec); + } + return uniqueSpecs; +} + +function addInsertion(insertions: Map, index: number, addedLines: string[]): void { + if (addedLines.length === 0) { + return; + } + + const existing = insertions.get(index); + if (existing) { + existing.push(...addedLines); + return; + } + + insertions.set(index, [...addedLines]); +} + +function insertChunk( + insertions: Map, + lines: string[], + index: number, + chunk: string[], + options: { blankBefore?: boolean; blankAfter?: boolean } = {}, +): void { + if (chunk.length === 0) { + return; + } + + const nextChunk = [...chunk]; + if (options.blankBefore && !isBlankLine(lines[index - 1] ?? "")) { + nextChunk.unshift(""); + } + if (options.blankAfter && !isBlankLine(lines[index] ?? "")) { + nextChunk.push(""); + } + + addInsertion(insertions, index, nextChunk); +} + +function sortSpecs(specs: ImportSpec[]): ImportSpec[] { + return [...specs].sort((left, right) => left.from.localeCompare(right.from)); +} + +function buildSectionLines(specs: ImportSpec[], style: ImportStyle): { sectionLines: string[]; addedLines: string[] } { + const systemLines = sortSpecs(specs.filter(spec => spec.system)).map(spec => specToLine(spec, style)); + const localLines = sortSpecs(specs.filter(spec => !spec.system)).map(spec => specToLine(spec, style)); + const sectionLines = [ + ...systemLines, + ...(systemLines.length > 0 && localLines.length > 0 && style.groupSeparator ? [""] : []), + ...localLines, + ]; + return { + sectionLines, + addedLines: [...systemLines, ...localLines], + }; +} + +function applyInsertions(lines: string[], insertions: Map): string[] { + const indexes = [...insertions.keys()].sort((left, right) => right - left); + for (const index of indexes) { + const chunk = insertions.get(index); + if (!chunk || chunk.length === 0) { + continue; + } + lines.splice(index, 0, ...chunk); + } + return lines; +} + +function applyExistingRegion( + content: string, + specs: ImportSpec[], + region: IncludeRegionAnalysis, +): { content: string; added: string[] } { + const lines = content.split("\n"); + const insertions = new Map(); + const groupedSpecs: Record = { + system: sortSpecs(specs.filter(spec => spec.system)), + local: sortSpecs(specs.filter(spec => !spec.system)), + }; + const groupedEntries: Record = { + system: region.entries.filter(entry => entry.group === "system"), + local: region.entries.filter(entry => entry.group === "local"), + }; + const addedLines: string[] = []; + + for (const group of region.groupOrder) { + const specsForGroup = groupedSpecs[group]; + if (specsForGroup.length === 0) { + continue; + } + + const entriesForGroup = groupedEntries[group]; + const otherGroup: IncludeGroup = group === "system" ? "local" : "system"; + const otherEntries = groupedEntries[otherGroup]; + const linesForGroup = specsForGroup.map(spec => specToLine(spec, region.style)); + addedLines.push(...linesForGroup); + + if (entriesForGroup.length === 0) { + const placeBeforeOther = region.groupOrder[0] === group; + if (placeBeforeOther) { + insertChunk(insertions, lines, otherEntries[0]?.lineIndex ?? region.startLine - 1, linesForGroup, { + blankAfter: otherEntries.length > 0 && region.style.groupSeparator, + }); + } else { + insertChunk( + insertions, + lines, + (lastItem(otherEntries)?.lineIndex ?? region.endLine - 1) + 1, + linesForGroup, + { + blankBefore: otherEntries.length > 0 && region.style.groupSeparator, + }, + ); + } + continue; + } + + if (!isGroupSorted(entriesForGroup)) { + addInsertion(insertions, (lastItem(entriesForGroup)?.lineIndex ?? region.endLine - 1) + 1, linesForGroup); + continue; + } + + const pendingByIndex = new Map(); + for (const spec of specsForGroup) { + const targetIndex = + entriesForGroup.find(entry => spec.from.localeCompare(entry.source) < 0)?.lineIndex ?? + (lastItem(entriesForGroup)?.lineIndex ?? region.endLine - 1) + 1; + addInsertion(pendingByIndex, targetIndex, [specToLine(spec, region.style)]); + } + + for (const [targetIndex, chunk] of pendingByIndex.entries()) { + addInsertion(insertions, targetIndex, chunk); + } + } + + const updatedLines = applyInsertions(lines, insertions); + return { content: updatedLines.join("\n"), added: addedLines }; +} + +export const cppImportHandler: ImportHandler = { + parseExisting, + specToLine, + apply(content, specs) { + const region = analyzeIncludeRegion(content); + const uniqueSpecs = dedupeSpecs( + specs, + region?.entries.map(entry => ({ from: entry.source, system: entry.system })) ?? [], + ); + if (uniqueSpecs.length === 0) { + return { content, added: [], warnings: [] }; + } + + if (!region) { + const lines = content.split("\n"); + const insertionPoint = findInsertionPoint(lines); + const { sectionLines, addedLines } = buildSectionLines(uniqueSpecs, defaultImportStyle); + const insertions = new Map(); + insertChunk(insertions, lines, insertionPoint, sectionLines, { + blankBefore: insertionPoint > 0, + blankAfter: insertionPoint < lines.length && !isBlankLine(lines[insertionPoint] ?? ""), + }); + const updatedLines = applyInsertions(lines, insertions); + return { content: updatedLines.join("\n"), added: addedLines, warnings: [] }; + } + + const result = applyExistingRegion(content, uniqueSpecs, region); + return { content: result.content, added: result.added, warnings: [] }; + }, +}; + +export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/go.ts b/packages/coding-agent/src/patch/imports/go.ts new file mode 100644 index 000000000..4d5b3367f --- /dev/null +++ b/packages/coding-agent/src/patch/imports/go.ts @@ -0,0 +1,435 @@ +import type { ImportSpec } from "../types"; +import { + defaultImportStyle, + type ImportHandler, + type ImportRegion, + type ImportStyle, + type ParsedImport, +} from "./types"; + +type GoImportGroup = "stdlib" | "thirdParty"; + +interface GoImportEntry extends ParsedImport { + group: GoImportGroup; +} + +interface ParsedGoImportRegion extends ImportRegion { + entries: GoImportEntry[]; + hasBlock: boolean; + packageLine: number; + newline: string; +} + +const GROUP_ORDER: GoImportGroup[] = ["stdlib", "thirdParty"]; +const SINGLE_IMPORT_RE = /^\s*import\s+(?:(?[._]|[A-Za-z_]\w*)\s+)?"(?[^"]+)"(?:\s*\/\/.*)?\s*$/; +const BLOCK_IMPORT_START_RE = /^\s*import\s*\(\s*(?:\/\/.*)?\s*$/; +const BLOCK_IMPORT_END_RE = /^\s*\)\s*(?:\/\/.*)?\s*$/; +const BLOCK_IMPORT_LINE_RE = /^\s*(?:(?[._]|[A-Za-z_]\w*)\s+)?"(?[^"]+)"(?:\s*\/\/.*)?\s*$/; +const PACKAGE_RE = /^\s*package\s+[A-Za-z_]\w*\s*(?:\/\/.*)?\s*$/; + +function detectNewline(content: string): string { + return content.includes("\r\n") ? "\r\n" : "\n"; +} + +function hasTrailingNewline(content: string): boolean { + return content.endsWith("\n"); +} + +function splitLines(content: string): string[] { + return content.split(/\r?\n/); +} + +function joinLines(lines: string[], newline: string, trailingNewline: boolean): string { + let result = lines.join(newline); + if (trailingNewline && (lines.length === 0 || lines[lines.length - 1] !== "")) { + result += newline; + } + return result; +} + +function isBlank(line: string): boolean { + return line.trim().length === 0; +} + +function isCommentLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/"); +} + +function isIgnorableTopLevelLine(line: string): boolean { + return isBlank(line) || isCommentLine(line); +} + +function classifyGroup(source: string): GoImportGroup { + const firstSegment = source.split("/")[0] ?? source; + return firstSegment.includes(".") ? "thirdParty" : "stdlib"; +} + +function parseImportSpec(line: string, expression: RegExp): GoImportEntry | null { + const match = expression.exec(line); + if (!match?.groups?.source) { + return null; + } + + const alias = match.groups.alias; + const source = match.groups.source; + return { + raw: line, + source, + names: [], + alias, + group: classifyGroup(source), + }; +} + +function findPackageLine(lines: string[]): number { + for (let index = 0; index < lines.length; index += 1) { + if (PACKAGE_RE.test(lines[index] ?? "")) { + return index + 1; + } + } + return -1; +} + +function detectGroupOrder(entries: GoImportEntry[]): string[] { + const seen = new Set(); + const order: string[] = []; + + for (const entry of entries) { + if (seen.has(entry.group)) { + continue; + } + seen.add(entry.group); + order.push(entry.group); + } + + return order.length > 0 ? order : [...GROUP_ORDER]; +} + +function parseExistingRegion(content: string): ParsedGoImportRegion | null { + const lines = splitLines(content); + const packageLine = findPackageLine(lines); + if (packageLine < 0) { + return null; + } + + let cursor = packageLine; + while (cursor < lines.length && isIgnorableTopLevelLine(lines[cursor] ?? "")) { + cursor += 1; + } + + if (cursor >= lines.length || !(lines[cursor]?.trimStart().startsWith("import") ?? false)) { + return null; + } + + const newline = detectNewline(content); + const entries: GoImportEntry[] = []; + const startLine = cursor + 1; + let endLine = cursor + 1; + let hasBlock = false; + let sawBlankGroupSeparator = false; + let previousEntryLine = -1; + + while (cursor < lines.length) { + const line = lines[cursor] ?? ""; + if (isIgnorableTopLevelLine(line)) { + cursor += 1; + continue; + } + + if (BLOCK_IMPORT_START_RE.test(line)) { + hasBlock = true; + cursor += 1; + while (cursor < lines.length) { + const innerLine = lines[cursor] ?? ""; + if (BLOCK_IMPORT_END_RE.test(innerLine)) { + endLine = cursor + 1; + cursor += 1; + break; + } + + if (isBlank(innerLine)) { + if (previousEntryLine > 0) { + sawBlankGroupSeparator = true; + } + cursor += 1; + continue; + } + + if (isCommentLine(innerLine)) { + cursor += 1; + continue; + } + + const parsed = parseImportSpec(innerLine, BLOCK_IMPORT_LINE_RE); + if (parsed) { + entries.push(parsed); + previousEntryLine = cursor + 1; + } + cursor += 1; + } + continue; + } + + const parsed = parseImportSpec(line, SINGLE_IMPORT_RE); + if (!parsed) { + break; + } + + entries.push(parsed); + endLine = cursor + 1; + cursor += 1; + + let lookahead = cursor; + while (lookahead < lines.length && isIgnorableTopLevelLine(lines[lookahead] ?? "")) { + if (isBlank(lines[lookahead] ?? "")) { + sawBlankGroupSeparator = true; + } + lookahead += 1; + } + if (lookahead >= lines.length || !(lines[lookahead]?.trimStart().startsWith("import") ?? false)) { + break; + } + cursor = lookahead; + } + + const style: ImportStyle = { + ...defaultImportStyle, + groupSeparator: sawBlankGroupSeparator, + groupOrder: detectGroupOrder(entries), + }; + + return { + startLine, + endLine, + imports: entries, + style, + entries, + hasBlock, + packageLine, + newline, + }; +} + +function parseExisting(content: string): ImportRegion | null { + return parseExistingRegion(content); +} + +function specToLine(spec: ImportSpec, _style: ImportStyle): string { + const aliasPrefix = spec.alias ? `${spec.alias} ` : ""; + return `${aliasPrefix}"${spec.from}"`; +} + +function normalizeRequestedImports(specs: ImportSpec[]): { imports: GoImportEntry[]; warnings: string[] } { + const warnings: string[] = []; + const bySource = new Map(); + + for (const spec of specs) { + if (spec.imports?.length) { + warnings.push(`Go imports do not support named imports from ${spec.from}; ignoring named bindings.`); + } + if (spec.default) { + warnings.push(`Go imports do not support default imports from ${spec.from}; ignoring default binding.`); + } + if (spec.namespace) { + warnings.push(`Go imports do not support namespace imports from ${spec.from}; ignoring namespace binding.`); + } + if (spec.system) { + warnings.push(`Go imports do not use system import mode for ${spec.from}; ignoring system flag.`); + } + + const normalized: GoImportEntry = { + raw: specToLine(spec, defaultImportStyle), + source: spec.from, + names: [], + alias: spec.alias, + group: classifyGroup(spec.from), + }; + + const existing = bySource.get(normalized.source); + if (!existing) { + bySource.set(normalized.source, normalized); + continue; + } + + if (existing.alias === normalized.alias) { + continue; + } + + warnings.push( + `Conflicting Go import aliases requested for ${normalized.source}; keeping ${formatAlias(existing.alias)}.`, + ); + } + + return { imports: [...bySource.values()], warnings }; +} + +function formatAlias(alias: string | undefined): string { + return alias ? `alias ${alias}` : "the existing unaliased import"; +} + +function trimLeadingBlankLines(lines: string[]): string[] { + let start = 0; + while (start < lines.length && isBlank(lines[start] ?? "")) { + start += 1; + } + return lines.slice(start); +} + +function pushGroupLines(lines: string[], imports: GoImportEntry[], style: ImportStyle): void { + const orderedGroups = style.groupOrder.filter( + (group): group is GoImportGroup => group === "stdlib" || group === "thirdParty", + ); + const groupOrder = orderedGroups.length > 0 ? orderedGroups : GROUP_ORDER; + let emittedGroups = 0; + + for (const group of groupOrder) { + const groupImports = imports.filter(entry => entry.group === group); + if (groupImports.length === 0) { + continue; + } + if (style.groupSeparator && emittedGroups > 0) { + lines.push(""); + } + for (const entry of groupImports) { + lines.push(`\t${specToLine({ from: entry.source, alias: entry.alias }, style)}`); + } + emittedGroups += 1; + } +} + +function buildImportSection(imports: GoImportEntry[], preferBlock: boolean, style: ImportStyle): string[] { + if (imports.length === 1 && !preferBlock) { + const [entry] = imports; + return [`import ${specToLine({ from: entry.source, alias: entry.alias }, style)}`]; + } + + const lines = ["import ("]; + pushGroupLines(lines, imports, style); + lines.push(")"); + return lines; +} + +function mergeImports( + existing: GoImportEntry[], + requested: GoImportEntry[], +): { merged: GoImportEntry[]; added: GoImportEntry[]; warnings: string[] } { + const warnings: string[] = []; + const added: GoImportEntry[] = []; + const mergedBySource = new Map(); + + for (const entry of existing) { + if (mergedBySource.has(entry.source)) { + const prior = mergedBySource.get(entry.source); + if (prior?.alias !== entry.alias) { + warnings.push( + `Conflicting existing Go import aliases for ${entry.source}; keeping ${formatAlias(prior?.alias)}.`, + ); + } + continue; + } + mergedBySource.set(entry.source, entry); + } + + for (const entry of requested) { + const prior = mergedBySource.get(entry.source); + if (!prior) { + mergedBySource.set(entry.source, entry); + added.push(entry); + continue; + } + if (prior.alias === entry.alias) { + continue; + } + warnings.push( + `Go import ${entry.source} already exists with ${formatAlias(prior.alias)}; skipping conflicting alias.`, + ); + } + + const groupBuckets = new Map(); + for (const group of GROUP_ORDER) { + groupBuckets.set(group, []); + } + for (const entry of mergedBySource.values()) { + groupBuckets.get(entry.group)?.push(entry); + } + for (const bucket of groupBuckets.values()) { + bucket.sort((left, right) => { + if (left.source === right.source) { + return (left.alias ?? "").localeCompare(right.alias ?? ""); + } + return left.source.localeCompare(right.source); + }); + } + + return { + merged: [...(groupBuckets.get("stdlib") ?? []), ...(groupBuckets.get("thirdParty") ?? [])], + added, + warnings, + }; +} + +function apply(content: string, specs: ImportSpec[]): { content: string; added: string[]; warnings: string[] } { + const requested = normalizeRequestedImports(specs); + const parsedRegion = parseExistingRegion(content); + const newline = parsedRegion?.newline ?? detectNewline(content); + const trailingNewline = hasTrailingNewline(content); + const warnings = [...requested.warnings]; + const lines = splitLines(content); + const packageLine = parsedRegion?.packageLine ?? findPackageLine(lines); + if (packageLine < 0) { + warnings.push("Go import management requires a package declaration."); + return { content, added: [], warnings }; + } + + const mergeResult = mergeImports(parsedRegion?.entries ?? [], requested.imports); + warnings.push(...mergeResult.warnings); + if (mergeResult.added.length === 0) { + return { content, added: [], warnings }; + } + + const style: ImportStyle = parsedRegion?.style ?? { + ...defaultImportStyle, + groupSeparator: true, + groupOrder: [...GROUP_ORDER], + }; + const preferBlock = Boolean(parsedRegion) || mergeResult.merged.length > 1; + const importSection = buildImportSection(mergeResult.merged, preferBlock, style); + + let nextLines: string[]; + if (parsedRegion) { + const before = lines.slice(0, parsedRegion.startLine - 1); + const after = trimLeadingBlankLines(lines.slice(parsedRegion.endLine)); + nextLines = [...before, ...importSection]; + if (after.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { + nextLines.push(""); + } + nextLines.push(...after); + } else { + const before = lines.slice(0, packageLine); + const after = trimLeadingBlankLines(lines.slice(packageLine)); + nextLines = [...before]; + if (!isBlank(nextLines[nextLines.length - 1] ?? "")) { + nextLines.push(""); + } + nextLines.push(...importSection); + if (after.length > 0) { + nextLines.push(""); + nextLines.push(...after); + } + } + + return { + content: joinLines(nextLines, newline, trailingNewline), + added: mergeResult.added.map(entry => specToLine({ from: entry.source, alias: entry.alias }, style)), + warnings, + }; +} + +export const goImportHandler: ImportHandler = { + parseExisting, + specToLine, + apply, +}; + +export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/index.ts b/packages/coding-agent/src/patch/imports/index.ts new file mode 100644 index 000000000..6b9e14986 --- /dev/null +++ b/packages/coding-agent/src/patch/imports/index.ts @@ -0,0 +1,60 @@ +import { detectLanguageId } from "../../lsp/utils"; +import type { ImportSpec } from "../types"; +import { cppImportHandler } from "./cpp"; +import { goImportHandler } from "./go"; +import { pythonImportHandler } from "./python"; +import { rustImportHandler } from "./rust"; +import type { ImportApplyResult, ImportHandler } from "./types"; +import { typescriptImportHandler } from "./typescript"; + +const handlers: Partial> = { + typescript: typescriptImportHandler, + typescriptreact: typescriptImportHandler, + javascript: typescriptImportHandler, + javascriptreact: typescriptImportHandler, + rust: rustImportHandler, + python: pythonImportHandler, + go: goImportHandler, + c: cppImportHandler, + cpp: cppImportHandler, +}; + +export interface ApplyImportsResult { + content: string; + warnings: string[]; + added: string[]; +} + +export function applyImports(filePath: string, content: string, specs: ImportSpec[]): ApplyImportsResult { + if (specs.length === 0) { + return { content, warnings: [], added: [] }; + } + + const languageId = detectLanguageId(filePath); + const handler = handlers[languageId]; + if (!handler) { + return { + content, + added: [], + warnings: [`Import management not supported for language: ${languageId}`], + }; + } + + try { + const result: ImportApplyResult = handler.apply(content, specs); + return { + content: result.content, + warnings: result.warnings, + added: result.added, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content, + added: [], + warnings: [`Failed to manage imports for ${filePath}: ${message}`], + }; + } +} + +export * from "./types"; diff --git a/packages/coding-agent/src/patch/imports/python.ts b/packages/coding-agent/src/patch/imports/python.ts new file mode 100644 index 000000000..c2480669d --- /dev/null +++ b/packages/coding-agent/src/patch/imports/python.ts @@ -0,0 +1,653 @@ +import type { ImportSpec } from "../types"; +import { + defaultImportStyle, + type ImportHandler, + type ImportRegion, + type ImportStyle, + type ParsedImport, +} from "./types"; + +type ImportGroup = "stdlib" | "thirdparty" | "local"; + +interface PythonImportStyle extends ImportStyle { + parenthesized: boolean; +} + +interface ImportedName { + name: string; + alias?: string; + order: number; +} + +interface ParsedPythonImport extends ParsedImport { + kind: "module" | "from"; + group: ImportGroup; + order: number; + multiline: boolean; + startLine: number; + endLine: number; + names: string[]; + parsedNames: ImportedName[]; +} + +interface ParsedPythonRegion extends ImportRegion { + imports: ParsedPythonImport[]; + style: PythonImportStyle; +} + +const DEFAULT_GROUP_ORDER: ImportGroup[] = ["stdlib", "thirdparty", "local"]; + +const STDLIB_MODULES = new Set([ + "__future__", + "abc", + "argparse", + "array", + "ast", + "asyncio", + "base64", + "collections", + "contextlib", + "copy", + "csv", + "dataclasses", + "datetime", + "decimal", + "enum", + "functools", + "glob", + "gzip", + "hashlib", + "heapq", + "hmac", + "html", + "http", + "importlib", + "inspect", + "io", + "itertools", + "json", + "logging", + "math", + "mimetypes", + "os", + "pathlib", + "pickle", + "platform", + "queue", + "random", + "re", + "secrets", + "shlex", + "shutil", + "socket", + "sqlite3", + "statistics", + "string", + "subprocess", + "sys", + "tempfile", + "threading", + "time", + "traceback", + "types", + "typing", + "unittest", + "urllib", + "uuid", + "warnings", + "weakref", + "xml", + "zipfile", + "zoneinfo", +]); + +function getPythonStyle(style: ImportStyle): PythonImportStyle { + return { + ...style, + parenthesized: (style as Partial).parenthesized ?? false, + }; +} + +function detectEol(content: string): string { + return content.includes("\r\n") ? "\r\n" : "\n"; +} + +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +function isCommentLine(line: string): boolean { + return line.trimStart().startsWith("#"); +} + +function isEncodingComment(line: string): boolean { + return /^[ \t]*#.*coding[:=][ \t]*[-\w.]+/.test(line); +} + +function isImportStart(line: string): boolean { + const trimmed = line.trimStart(); + return trimmed.startsWith("import ") || trimmed.startsWith("from "); +} + +function stripInlineComment(line: string): string { + const hashIndex = line.indexOf("#"); + return hashIndex === -1 ? line : line.slice(0, hashIndex); +} + +function parseModuleDocstring(lines: string[], startIndex: number): number | null { + const firstLine = lines[startIndex]; + if (firstLine === undefined) return null; + const trimmed = firstLine.trimStart(); + const match = /^[rRuUbBfF]*(["']{3})/.exec(trimmed); + if (!match) return null; + const delimiter = match[1]; + const remainder = trimmed.slice(match[0].length); + if (remainder.includes(delimiter)) return startIndex + 1; + for (let index = startIndex + 1; index < lines.length; index += 1) { + if (lines[index]?.includes(delimiter)) return index + 1; + } + return lines.length; +} + +function findPrefixEnd(lines: string[]): number { + let index = 0; + if (lines[0]?.startsWith("#!")) index += 1; + if (isEncodingComment(lines[index] ?? "")) index += 1; + while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; + const docstringEnd = parseModuleDocstring(lines, index); + if (docstringEnd !== null) { + index = docstringEnd; + while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; + } + return index; +} + +function classifyGroup(source: string): ImportGroup { + if (source.startsWith(".")) return "local"; + const root = source.split(".")[0] ?? source; + return STDLIB_MODULES.has(root) ? "stdlib" : "thirdparty"; +} + +function formatImportedName(name: ImportedName): string { + return name.alias ? `${name.name} as ${name.alias}` : name.name; +} + +function parseImportedName(token: string, order: number): ImportedName | null { + const cleaned = stripInlineComment(token).replace(/,$/, "").trim(); + if (cleaned.length === 0) return null; + const match = /^(\*|[A-Za-z_][\w]*)(?:\s+as\s+([A-Za-z_][\w]*))?$/.exec(cleaned); + if (!match) return null; + return { name: match[1], alias: match[2], order }; +} + +function parseModuleImport(token: string, order: number): ParsedPythonImport | null { + const cleaned = stripInlineComment(token).replace(/,$/, "").trim(); + if (cleaned.length === 0) return null; + const match = /^([A-Za-z_][\w.]*)(?:\s+as\s+([A-Za-z_][\w]*))?$/.exec(cleaned); + if (!match) return null; + return { + raw: `import ${cleaned}`, + source: match[1], + names: [], + parsedNames: [], + alias: match[2], + kind: "module", + group: classifyGroup(match[1]), + order, + multiline: false, + startLine: 0, + endLine: 0, + }; +} + +function parseImportStatement( + line: string, + lineNumber: number, + startOrder: number, +): { records: ParsedPythonImport[]; nextOrder: number } | null { + const match = /^import\s+(.+)$/.exec(stripInlineComment(line.trim())); + if (!match) return null; + let nextOrder = startOrder; + const records: ParsedPythonImport[] = []; + for (const token of match[1].split(",")) { + const parsed = parseModuleImport(token, nextOrder); + if (!parsed) continue; + parsed.startLine = lineNumber; + parsed.endLine = lineNumber; + parsed.raw = line; + records.push(parsed); + nextOrder += 1; + } + return records.length === 0 ? null : { records, nextOrder }; +} + +function countParentheses(text: string): number { + let balance = 0; + for (const char of text) { + if (char === "(") balance += 1; + if (char === ")") balance -= 1; + } + return balance; +} + +function parseFromStatement( + lines: string[], + startIndex: number, + eol: string, + startOrder: number, +): { record: ParsedPythonImport; nextIndex: number; nextOrder: number } | null { + const collected: string[] = []; + let nextIndex = startIndex; + let balance = 0; + let sawParenthesis = false; + while (nextIndex < lines.length) { + const line = lines[nextIndex] ?? ""; + collected.push(line); + const withoutComment = stripInlineComment(line); + balance += countParentheses(withoutComment); + sawParenthesis ||= withoutComment.includes("("); + nextIndex += 1; + if (sawParenthesis) { + if (balance <= 0) break; + continue; + } + break; + } + const normalized = collected + .map(line => stripInlineComment(line).trim()) + .join(" ") + .replace(/\s+/g, " "); + const match = /^from\s+([.A-Za-z_][\w.]*)\s+import\s+(.+)$/.exec(normalized); + if (!match) return null; + let namesPart = match[2].trim(); + if (namesPart.startsWith("(") && namesPart.endsWith(")")) { + namesPart = namesPart.slice(1, -1).trim(); + } + const parsedNames: ImportedName[] = []; + let nextOrder = startOrder; + for (const token of namesPart.split(",")) { + const parsedName = parseImportedName(token, nextOrder); + if (!parsedName) continue; + parsedNames.push(parsedName); + nextOrder += 1; + } + if (parsedNames.length === 0) return null; + const record: ParsedPythonImport = { + raw: collected.join(eol), + source: match[1], + names: parsedNames.map(formatImportedName), + parsedNames, + kind: "from", + group: classifyGroup(match[1]), + order: startOrder, + multiline: sawParenthesis || collected.length > 1, + startLine: startIndex + 1, + endLine: nextIndex, + }; + return { record, nextIndex, nextOrder }; +} + +function compareImportedNames(left: ImportedName, right: ImportedName): number { + const leftKey = `${left.name}|${left.alias ?? ""}`; + const rightKey = `${right.name}|${right.alias ?? ""}`; + return leftKey.localeCompare(rightKey); +} + +function compareRecords(left: ParsedPythonImport, right: ParsedPythonImport): number { + const leftKey = left.kind === "module" ? `import ${left.source} ${left.alias ?? ""}` : `from ${left.source}`; + const rightKey = right.kind === "module" ? `import ${right.source} ${right.alias ?? ""}` : `from ${right.source}`; + return leftKey.localeCompare(rightKey); +} + +function hasBlankLineBetween(lines: string[], previousEndLine: number, nextStartLine: number): boolean { + for (let index = previousEndLine; index < nextStartLine - 1; index += 1) { + if (isBlankLine(lines[index] ?? "")) return true; + } + return false; +} + +function inferSorted(records: ParsedPythonImport[]): boolean { + const byGroup = new Map(); + for (const record of records) { + const groupRecords = byGroup.get(record.group); + if (groupRecords) groupRecords.push(record); + else byGroup.set(record.group, [record]); + if (record.kind === "from") { + for (let index = 1; index < record.parsedNames.length; index += 1) { + if (compareImportedNames(record.parsedNames[index - 1]!, record.parsedNames[index]!) > 0) return false; + } + } + } + for (const group of DEFAULT_GROUP_ORDER) { + const groupRecords = byGroup.get(group) ?? []; + for (let index = 1; index < groupRecords.length; index += 1) { + if (compareRecords(groupRecords[index - 1]!, groupRecords[index]!) > 0) return false; + } + } + return true; +} + +function inferStyle(records: ParsedPythonImport[], lines: string[]): PythonImportStyle { + const groupOrder: ImportGroup[] = []; + let groupSeparator = false; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]!; + if (!groupOrder.includes(record.group)) groupOrder.push(record.group); + if (index > 0) { + const previous = records[index - 1]!; + if (hasBlankLineBetween(lines, previous.endLine, record.startLine)) groupSeparator = true; + } + } + return { + ...defaultImportStyle, + groupSeparator, + groupOrder: groupOrder.length > 0 ? groupOrder : [...DEFAULT_GROUP_ORDER], + sorted: inferSorted(records), + parenthesized: records.some(record => record.kind === "from" && record.multiline), + }; +} + +function normalizeRecords(records: ParsedPythonImport[]): ParsedPythonImport[] { + const moduleRecords = new Map(); + const fromRecords = new Map(); + for (const record of [...records].sort((left, right) => left.order - right.order)) { + if (record.kind === "module") { + const key = `${record.source}|${record.alias ?? ""}`; + if (!moduleRecords.has(key)) moduleRecords.set(key, { ...record, parsedNames: [], names: [] }); + continue; + } + const existing = fromRecords.get(record.source); + if (!existing) { + fromRecords.set(record.source, { + ...record, + parsedNames: [...record.parsedNames], + names: [...record.names], + }); + continue; + } + for (const name of record.parsedNames) { + const alreadyPresent = existing.parsedNames.some( + existingName => existingName.name === name.name && (existingName.alias ?? "") === (name.alias ?? ""), + ); + if (alreadyPresent) continue; + existing.parsedNames.push({ ...name, order: existing.parsedNames.length }); + } + existing.names = existing.parsedNames.map(formatImportedName); + existing.multiline ||= record.multiline; + if (record.order < existing.order) existing.order = record.order; + } + const combined = [...moduleRecords.values(), ...fromRecords.values()]; + return combined.sort((left, right) => left.order - right.order); +} + +function renderRecord(record: ParsedPythonImport, style: PythonImportStyle): string { + if (record.kind === "module") { + return record.alias ? `import ${record.source} as ${record.alias}` : `import ${record.source}`; + } + const names = [...record.parsedNames]; + if (style.sorted) names.sort(compareImportedNames); + const renderedNames = names.map(formatImportedName); + const useParenthesized = record.multiline || (style.parenthesized && renderedNames.length > 1); + if (!useParenthesized) { + return `from ${record.source} import ${renderedNames.join(", ")}`; + } + return [`from ${record.source} import (`, ...renderedNames.map(name => ` ${name},`), `)`].join("\n"); +} + +function groupComparator(left: ImportGroup, right: ImportGroup, style: PythonImportStyle): number { + const orderedGroups = [ + ...style.groupOrder, + ...DEFAULT_GROUP_ORDER.filter(group => !style.groupOrder.includes(group)), + ]; + return orderedGroups.indexOf(left) - orderedGroups.indexOf(right); +} + +function renderImportBlock(records: ParsedPythonImport[], style: PythonImportStyle): string[] { + const normalized = normalizeRecords(records).map(record => ({ + ...record, + parsedNames: [...record.parsedNames], + names: [...record.names], + })); + const grouped = new Map(); + for (const record of normalized) { + const groupRecords = grouped.get(record.group); + if (groupRecords) groupRecords.push(record); + else grouped.set(record.group, [record]); + } + const orderedGroups = [...grouped.keys()].sort((left, right) => groupComparator(left, right, style)); + const renderedLines: string[] = []; + for (const group of orderedGroups) { + const groupRecords = grouped.get(group) ?? []; + const orderedRecords = style.sorted + ? [...groupRecords].sort(compareRecords) + : [...groupRecords].sort((left, right) => left.order - right.order); + if (renderedLines.length > 0 && style.groupSeparator) renderedLines.push(""); + for (const record of orderedRecords) { + renderedLines.push(...renderRecord(record, style).split("\n")); + } + } + return renderedLines; +} + +function specToRecords(spec: ImportSpec, order: number): { records: ParsedPythonImport[]; warnings: string[] } { + const warnings: string[] = []; + if (spec.default) warnings.push(`Python imports do not support default import syntax for ${spec.from}`); + if (spec.namespace) warnings.push(`Python imports do not support namespace import syntax for ${spec.from}`); + if (spec.imports && spec.imports.length > 0) { + const parsedNames = spec.imports.flatMap((item, index) => { + const parsed = parseImportedName(item, order + index); + if (parsed) return [parsed]; + return []; + }); + if (parsedNames.length === 1 && spec.alias) parsedNames[0] = { ...parsedNames[0]!, alias: spec.alias }; + if (parsedNames.length === 0) return { records: [], warnings }; + return { + records: [ + { + raw: "", + source: spec.from, + names: parsedNames.map(formatImportedName), + parsedNames, + kind: "from", + group: classifyGroup(spec.from), + order, + multiline: false, + startLine: 0, + endLine: 0, + }, + ], + warnings, + }; + } + return { + records: [ + { + raw: "", + source: spec.from, + names: [], + parsedNames: [], + alias: spec.alias, + kind: "module", + group: classifyGroup(spec.from), + order, + multiline: false, + startLine: 0, + endLine: 0, + }, + ], + warnings, + }; +} + +function parsePythonRegion(content: string): ParsedPythonRegion | null { + const lines = content.split(/\r?\n/); + const eol = detectEol(content); + const prefixEnd = findPrefixEnd(lines); + let index = prefixEnd; + while (index < lines.length && (isBlankLine(lines[index] ?? "") || isCommentLine(lines[index] ?? ""))) index += 1; + if (!isImportStart(lines[index] ?? "")) return null; + const records: ParsedPythonImport[] = []; + let nextOrder = 0; + while (index < lines.length) { + const currentLine = lines[index] ?? ""; + if (isBlankLine(currentLine) || isCommentLine(currentLine)) { + index += 1; + continue; + } + if (currentLine.trimStart().startsWith("import ")) { + const parsed = parseImportStatement(currentLine, index + 1, nextOrder); + if (!parsed) break; + records.push(...parsed.records); + nextOrder = parsed.nextOrder; + index += 1; + continue; + } + if (currentLine.trimStart().startsWith("from ")) { + const parsed = parseFromStatement(lines, index, eol, nextOrder); + if (!parsed) break; + records.push(parsed.record); + nextOrder = parsed.nextOrder; + index = parsed.nextIndex; + continue; + } + break; + } + if (records.length === 0) return null; + const normalizedRecords = normalizeRecords(records); + return { + startLine: normalizedRecords[0]!.startLine, + endLine: Math.max(...normalizedRecords.map(record => record.endLine)), + imports: normalizedRecords, + style: inferStyle(normalizedRecords, lines), + }; +} + +function parseExisting(content: string): ImportRegion | null { + return parsePythonRegion(content); +} + +function specToLine(spec: ImportSpec, style: ImportStyle): string { + const pythonStyle = getPythonStyle(style); + const { records } = specToRecords(spec, 0); + return records.length === 0 ? "" : renderRecord(records[0]!, pythonStyle); +} + +function mergeRecords( + existing: ParsedPythonImport[], + requested: ParsedPythonImport[], + style: PythonImportStyle, +): { records: ParsedPythonImport[]; added: string[] } { + const records = normalizeRecords(existing).map(record => ({ + ...record, + parsedNames: [...record.parsedNames], + names: [...record.names], + })); + let nextOrder = records.reduce((max, record) => Math.max(max, record.order), -1) + 1; + const added: string[] = []; + for (const record of requested) { + if (record.kind === "module") { + const exists = records.some( + existingRecord => + existingRecord.kind === "module" && + existingRecord.source === record.source && + (existingRecord.alias ?? "") === (record.alias ?? ""), + ); + if (exists) continue; + records.push({ ...record, order: nextOrder, startLine: 0, endLine: 0 }); + added.push(renderRecord(record, style)); + nextOrder += 1; + continue; + } + const existingRecord = records.find(candidate => candidate.kind === "from" && candidate.source === record.source); + if (!existingRecord || existingRecord.kind !== "from") { + records.push({ ...record, order: nextOrder, startLine: 0, endLine: 0 }); + added.push(renderRecord(record, style)); + nextOrder += 1; + continue; + } + const missingNames = record.parsedNames.filter( + name => + !existingRecord.parsedNames.some( + existingName => existingName.name === name.name && (existingName.alias ?? "") === (name.alias ?? ""), + ), + ); + if (missingNames.length === 0) continue; + for (const name of missingNames) { + existingRecord.parsedNames.push({ ...name, order: existingRecord.parsedNames.length }); + } + if (style.sorted) existingRecord.parsedNames.sort(compareImportedNames); + existingRecord.names = existingRecord.parsedNames.map(formatImportedName); + existingRecord.multiline ||= record.multiline; + added.push( + renderRecord( + { ...existingRecord, parsedNames: missingNames, names: missingNames.map(formatImportedName) }, + style, + ), + ); + } + return { records, added }; +} + +function insertWithoutRegion(content: string, lines: string[], blockLines: string[], eol: string): string { + if (content.length === 0) return blockLines.join(eol); + const insertAt = findPrefixEnd(lines); + const before = lines.slice(0, insertAt); + const after = lines.slice(insertAt); + const needsTrailingBlank = + after.length > 0 && after.some(line => line.trim().length > 0) && !isBlankLine(after[0] ?? ""); + const inserted = [...before, ...blockLines, ...(needsTrailingBlank ? [""] : []), ...after]; + const nextContent = inserted.join(eol); + if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; + return nextContent; +} + +function replaceRegion( + content: string, + lines: string[], + region: ParsedPythonRegion, + blockLines: string[], + eol: string, +): string { + const nextLines = [...lines.slice(0, region.startLine - 1), ...blockLines, ...lines.slice(region.endLine)]; + const nextContent = nextLines.join(eol); + if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; + return nextContent; +} + +export const pythonImportHandler: ImportHandler = { + parseExisting, + specToLine, + apply(content, specs) { + const region = parsePythonRegion(content); + const style = getPythonStyle( + region?.style ?? { + ...defaultImportStyle, + groupSeparator: true, + groupOrder: [...DEFAULT_GROUP_ORDER], + sorted: true, + parenthesized: false, + }, + ); + const warnings: string[] = []; + const requested: ParsedPythonImport[] = []; + let order = 0; + for (const spec of specs) { + const result = specToRecords(spec, order); + warnings.push(...result.warnings); + requested.push(...result.records); + order += Math.max(result.records.length, 1); + } + if (requested.length === 0) return { content, added: [], warnings }; + const { records, added } = mergeRecords(region?.imports ?? [], requested, style); + if (added.length === 0) return { content, added: [], warnings }; + const eol = detectEol(content); + const lines = content.split(/\r?\n/); + const blockLines = renderImportBlock(records, style); + const nextContent = region + ? replaceRegion(content, lines, region, blockLines, eol) + : insertWithoutRegion(content, lines, blockLines, eol); + return { content: nextContent, added, warnings }; + }, +}; + +export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/rust.ts b/packages/coding-agent/src/patch/imports/rust.ts new file mode 100644 index 000000000..7f299ffd8 --- /dev/null +++ b/packages/coding-agent/src/patch/imports/rust.ts @@ -0,0 +1,594 @@ +import type { ImportSpec } from "../types"; +import { + defaultImportStyle, + type ImportHandler, + type ImportRegion, + type ImportStyle, + type ParsedImport, +} from "./types"; + +type RustImportGroup = "std" | "external" | "local"; +type RustImportKind = "simple" | "group"; + +interface RustImportEntry extends ParsedImport { + group: RustImportGroup; + kind: RustImportKind; + path: string; + prefix?: string; + members: string[]; + order: number; +} + +interface ParsedRustRegion extends ImportRegion { + imports: RustImportEntry[]; + entries: RustImportEntry[]; + newline: string; +} + +const DEFAULT_GROUP_ORDER: RustImportGroup[] = ["std", "external", "local"]; + +function detectNewline(content: string): string { + return content.includes("\r\n") ? "\r\n" : "\n"; +} + +function hasTrailingNewline(content: string): boolean { + return content.endsWith("\n"); +} + +function splitLines(content: string): string[] { + return content.split(/\r?\n/); +} + +function joinLines(lines: string[], newline: string, trailingNewline: boolean): string { + let result = lines.join(newline); + if (trailingNewline && (lines.length === 0 || lines[lines.length - 1] !== "")) { + result += newline; + } + return result; +} + +function isBlank(line: string): boolean { + return line.trim().length === 0; +} + +function isLineComment(line: string): boolean { + const trimmed = line.trimStart(); + return trimmed.startsWith("//"); +} + +function isBlockCommentStart(line: string): boolean { + return line.trimStart().startsWith("/*"); +} + +function isOuterAttribute(line: string): boolean { + const trimmed = line.trimStart(); + return trimmed.startsWith("#![") || trimmed.startsWith("#["); +} + +function consumeBlockComment(lines: string[], startIndex: number): number { + let index = startIndex; + while (index < lines.length) { + if ((lines[index] ?? "").includes("*/")) { + return index + 1; + } + index += 1; + } + return lines.length; +} + +function stripLineComment(line: string): string { + const markerIndex = line.indexOf("//"); + return markerIndex === -1 ? line : line.slice(0, markerIndex); +} + +function normalizeWhitespace(text: string): string { + return text.trim().replace(/\s+/g, " "); +} + +function normalizeMemberName(member: string): string { + return normalizeWhitespace(member).replace(/\s+as\s+/g, " as "); +} + +function classifyGroup(path: string): RustImportGroup { + const root = path.split("::")[0] ?? path; + if (root === "std" || root === "core") { + return "std"; + } + if (root === "crate" || root === "self" || root === "super") { + return "local"; + } + return "external"; +} + +function detectGroupOrder(entries: RustImportEntry[]): string[] { + const seen = new Set(); + const order: string[] = []; + for (const entry of entries) { + if (seen.has(entry.group)) { + continue; + } + seen.add(entry.group); + order.push(entry.group); + } + return order.length > 0 ? order : [...DEFAULT_GROUP_ORDER]; +} + +function findPrefixEnd(lines: string[]): number { + let index = 0; + while (index < lines.length && isBlank(lines[index] ?? "")) { + index += 1; + } + + if ((lines[index] ?? "").startsWith("#!") && !(lines[index] ?? "").trimStart().startsWith("#![")) { + index += 1; + } + + while (index < lines.length) { + const line = lines[index] ?? ""; + if (isBlank(line) || isLineComment(line) || isOuterAttribute(line)) { + index += 1; + continue; + } + if (isBlockCommentStart(line)) { + index = consumeBlockComment(lines, index); + continue; + } + break; + } + + return index; +} + +function parseSimpleExpression(expression: string): { path: string; alias?: string } | null { + const match = /^(?.+?)(?:\s+as\s+(?[A-Za-z_][A-Za-z0-9_]*))?$/.exec(expression.trim()); + if (!match?.groups?.path) { + return null; + } + + const path = normalizeWhitespace(match.groups.path); + if (path.length === 0 || path.includes("{") || path.includes("}")) { + return null; + } + + return { + path, + alias: match.groups.alias, + }; +} + +function parseMembers(rawMembers: string): string[] | null { + const members: string[] = []; + for (const token of rawMembers.split(",")) { + const normalized = normalizeMemberName(token); + if (normalized.length === 0) { + continue; + } + if (normalized.includes("{") || normalized.includes("}")) { + return null; + } + members.push(normalized); + } + return members.length > 0 ? members : null; +} + +function parseUseStatement(line: string, order: number): RustImportEntry | null { + const withoutComment = stripLineComment(line).trim(); + const match = /^use\s+(.+);$/.exec(withoutComment); + if (!match) { + return null; + } + + const expression = normalizeWhitespace(match[1]); + const groupMatch = /^(?.+?)::\{(?[^{}]+)\}$/.exec(expression); + if (groupMatch?.groups?.prefix && groupMatch.groups.members) { + const prefix = normalizeWhitespace(groupMatch.groups.prefix); + const members = parseMembers(groupMatch.groups.members); + if (!members) { + return null; + } + return { + raw: line, + source: prefix, + names: [...members], + path: prefix, + prefix, + members, + kind: "group", + group: classifyGroup(prefix), + order, + }; + } + + const simple = parseSimpleExpression(expression); + if (!simple) { + return null; + } + + return { + raw: line, + source: simple.path, + names: [], + alias: simple.alias, + path: simple.path, + members: [], + kind: "simple", + group: classifyGroup(simple.path), + order, + }; +} + +function parseExistingRegion(content: string): ParsedRustRegion | null { + const lines = splitLines(content); + const prefixEnd = findPrefixEnd(lines); + let cursor = prefixEnd; + while (cursor < lines.length && isBlank(lines[cursor] ?? "")) { + cursor += 1; + } + const startLine = cursor + 1; + if (!(lines[cursor] ?? "").trimStart().startsWith("use ")) { + return null; + } + + const entries: RustImportEntry[] = []; + let endLine = cursor + 1; + let order = 0; + let sawBlankGroupSeparator = false; + let seenImport = false; + + while (cursor < lines.length) { + const line = lines[cursor] ?? ""; + if (isBlank(line)) { + if (seenImport) { + sawBlankGroupSeparator = true; + endLine = cursor + 1; + } + cursor += 1; + continue; + } + if (isLineComment(line)) { + if (seenImport) { + endLine = cursor + 1; + } + cursor += 1; + continue; + } + if (isBlockCommentStart(line)) { + if (!seenImport) { + break; + } + const nextCursor = consumeBlockComment(lines, cursor); + endLine = nextCursor; + cursor = nextCursor; + continue; + } + const parsed = parseUseStatement(line, order); + if (!parsed) { + break; + } + entries.push(parsed); + seenImport = true; + endLine = cursor + 1; + cursor += 1; + order += 1; + } + + if (entries.length === 0) { + return null; + } + + return { + startLine, + endLine, + imports: entries, + entries, + style: { + ...defaultImportStyle, + groupSeparator: sawBlankGroupSeparator, + groupOrder: detectGroupOrder(entries), + sorted: true, + }, + newline: detectNewline(content), + }; +} + +function parseExisting(content: string): ImportRegion | null { + return parseExistingRegion(content); +} + +function renderSimpleExpression(path: string, alias?: string): string { + return alias ? `${path} as ${alias}` : path; +} + +function renderUseLine(entry: RustImportEntry): string { + if (entry.kind === "group") { + const prefix = entry.prefix ?? entry.path; + return `use ${prefix}::{${entry.members.join(", ")}};`; + } + return `use ${renderSimpleExpression(entry.path, entry.alias)};`; +} + +function specToLine(spec: ImportSpec, _style: ImportStyle): string { + if (spec.imports && spec.imports.length > 0) { + const members = spec.imports.map(item => normalizeMemberName(item)).filter(item => item.length > 0); + return `use ${spec.from}::{${members.join(", ")}};`; + } + return `use ${renderSimpleExpression(spec.from, spec.alias)};`; +} + +function trimLeadingBlankLines(lines: string[]): string[] { + let index = 0; + while (index < lines.length && isBlank(lines[index] ?? "")) { + index += 1; + } + return lines.slice(index); +} + +function createSimpleEntry(path: string, alias: string | undefined, order: number): RustImportEntry { + return { + raw: `use ${renderSimpleExpression(path, alias)};`, + source: path, + names: [], + alias, + path, + members: [], + kind: "simple", + group: classifyGroup(path), + order, + }; +} + +function createGroupEntry(prefix: string, members: string[], order: number): RustImportEntry { + const normalizedMembers = [ + ...new Set(members.map(member => normalizeMemberName(member)).filter(member => member.length > 0)), + ]; + return { + raw: `use ${prefix}::{${normalizedMembers.join(", ")}};`, + source: prefix, + names: [...normalizedMembers], + path: prefix, + prefix, + members: normalizedMembers, + kind: "group", + group: classifyGroup(prefix), + order, + }; +} + +function normalizeRequestedImports(specs: ImportSpec[]): { entries: RustImportEntry[]; warnings: string[] } { + const warnings: string[] = []; + const entries: RustImportEntry[] = []; + let order = 0; + + for (const spec of specs) { + if (spec.default) { + warnings.push(`Rust imports do not support default imports from ${spec.from}; ignoring default binding.`); + } + if (spec.namespace) { + warnings.push(`Rust imports do not support namespace imports from ${spec.from}; ignoring namespace binding.`); + } + if (spec.system) { + warnings.push(`Rust imports do not use system import mode for ${spec.from}; ignoring system flag.`); + } + + const normalizedFrom = normalizeWhitespace(spec.from); + if (normalizedFrom.length === 0) { + continue; + } + + if (spec.imports && spec.imports.length > 0) { + const members = spec.imports.map(item => normalizeMemberName(item)).filter(item => item.length > 0); + if (members.length === 0) { + continue; + } + entries.push(createGroupEntry(normalizedFrom, members, order)); + order += 1; + continue; + } + + entries.push(createSimpleEntry(normalizedFrom, spec.alias, order)); + order += 1; + } + + return { entries, warnings }; +} + +function simpleKey(path: string, alias: string | undefined): string { + return alias ? `${path} as ${alias}` : path; +} + +function splitSimplePath(path: string): { prefix: string; member: string } | null { + const separatorIndex = path.lastIndexOf("::"); + if (separatorIndex <= 0 || separatorIndex === path.length - 2) { + return null; + } + return { + prefix: path.slice(0, separatorIndex), + member: path.slice(separatorIndex + 2), + }; +} + +function sortMembers(members: Iterable): string[] { + return [...new Set(members)].sort((left, right) => left.localeCompare(right)); +} + +function compareEntries(left: RustImportEntry, right: RustImportEntry): number { + const leftKey = left.kind === "group" ? `${left.path}::{` : renderSimpleExpression(left.path, left.alias); + const rightKey = right.kind === "group" ? `${right.path}::{` : renderSimpleExpression(right.path, right.alias); + return leftKey.localeCompare(rightKey); +} + +function mergeImports( + existing: RustImportEntry[], + requested: RustImportEntry[], +): { merged: RustImportEntry[]; added: RustImportEntry[] } { + const simpleEntries = new Map(); + const aliaslessSimplePaths = new Set(); + const groupEntries = new Map(); + let nextOrder = existing.length; + + for (const entry of existing) { + if (entry.kind === "simple") { + simpleEntries.set(simpleKey(entry.path, entry.alias), createSimpleEntry(entry.path, entry.alias, entry.order)); + if (!entry.alias) { + aliaslessSimplePaths.add(entry.path); + } + continue; + } + + const prior = groupEntries.get(entry.path); + if (!prior) { + groupEntries.set(entry.path, createGroupEntry(entry.path, entry.members, entry.order)); + continue; + } + prior.members = sortMembers([...prior.members, ...entry.members]); + prior.names = [...prior.members]; + } + + const addedSimple = new Map(); + const addedGroupMembers = new Map>(); + + for (const entry of requested) { + if (entry.kind === "simple") { + const key = simpleKey(entry.path, entry.alias); + if (simpleEntries.has(key)) { + continue; + } + if (!entry.alias) { + const pathParts = splitSimplePath(entry.path); + if (pathParts) { + const grouped = groupEntries.get(pathParts.prefix); + if (grouped?.members.includes(pathParts.member)) { + continue; + } + } + } + const normalizedEntry = createSimpleEntry(entry.path, entry.alias, nextOrder); + nextOrder += 1; + simpleEntries.set(key, normalizedEntry); + addedSimple.set(key, normalizedEntry); + if (!entry.alias) { + aliaslessSimplePaths.add(entry.path); + } + continue; + } + + let mergedGroup = groupEntries.get(entry.path); + if (!mergedGroup) { + mergedGroup = createGroupEntry(entry.path, [], nextOrder); + nextOrder += 1; + groupEntries.set(entry.path, mergedGroup); + } + + const groupAdditions = addedGroupMembers.get(entry.path) ?? new Set(); + for (const member of entry.members) { + const simplePath = `${entry.path}::${member}`; + if (aliaslessSimplePaths.has(simplePath) || mergedGroup.members.includes(member)) { + continue; + } + mergedGroup.members = sortMembers([...mergedGroup.members, member]); + mergedGroup.names = [...mergedGroup.members]; + groupAdditions.add(member); + } + if (groupAdditions.size > 0) { + addedGroupMembers.set(entry.path, groupAdditions); + } + } + + const merged = [...simpleEntries.values(), ...groupEntries.values()].sort(compareEntries); + const added = [ + ...addedSimple.values(), + ...Array.from(addedGroupMembers.entries(), ([prefix, members]) => { + const entry = createGroupEntry(prefix, sortMembers(members), nextOrder); + nextOrder += 1; + return entry; + }), + ].sort(compareEntries); + return { merged, added }; +} + +function buildImportSection(entries: RustImportEntry[], style: ImportStyle): string[] { + const requestedOrder = style.groupOrder.filter( + (group): group is RustImportGroup => group === "std" || group === "external" || group === "local", + ); + const groupOrder = [...requestedOrder]; + for (const fallbackGroup of DEFAULT_GROUP_ORDER) { + if (!groupOrder.includes(fallbackGroup)) { + groupOrder.push(fallbackGroup); + } + } + + const lines: string[] = []; + let emittedGroups = 0; + for (const group of groupOrder) { + const groupEntries = entries.filter(entry => entry.group === group).sort(compareEntries); + if (groupEntries.length === 0) { + continue; + } + if (style.groupSeparator && emittedGroups > 0) { + lines.push(""); + } + for (const entry of groupEntries) { + lines.push(renderUseLine(entry)); + } + emittedGroups += 1; + } + return lines; +} + +function apply(content: string, specs: ImportSpec[]): { content: string; added: string[]; warnings: string[] } { + const requested = normalizeRequestedImports(specs); + const parsedRegion = parseExistingRegion(content); + const style: ImportStyle = parsedRegion?.style ?? { + ...defaultImportStyle, + groupSeparator: true, + groupOrder: [...DEFAULT_GROUP_ORDER], + sorted: true, + }; + const mergeResult = mergeImports(parsedRegion?.entries ?? [], requested.entries); + if (mergeResult.added.length === 0) { + return { content, added: [], warnings: requested.warnings }; + } + + const lines = splitLines(content); + const newline = parsedRegion?.newline ?? detectNewline(content); + const trailingNewline = hasTrailingNewline(content); + const importSection = buildImportSection(mergeResult.merged, style); + let nextLines: string[]; + + if (parsedRegion) { + const before = lines.slice(0, parsedRegion.startLine - 1); + const after = trimLeadingBlankLines(lines.slice(parsedRegion.endLine)); + nextLines = [...before, ...importSection]; + if (after.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { + nextLines.push(""); + } + nextLines.push(...after); + } else { + const insertionLine = findPrefixEnd(lines); + const before = lines.slice(0, insertionLine); + const after = trimLeadingBlankLines(lines.slice(insertionLine)); + nextLines = [...before]; + if (nextLines.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { + nextLines.push(""); + } + nextLines.push(...importSection); + if (after.length > 0) { + nextLines.push(""); + nextLines.push(...after); + } + } + + return { + content: joinLines(nextLines, newline, trailingNewline), + added: mergeResult.added.map(entry => renderUseLine(entry)), + warnings: requested.warnings, + }; +} + +export const rustImportHandler: ImportHandler = { + parseExisting, + specToLine, + apply, +}; + +export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/types.ts b/packages/coding-agent/src/patch/imports/types.ts new file mode 100644 index 000000000..6bf67bc3a --- /dev/null +++ b/packages/coding-agent/src/patch/imports/types.ts @@ -0,0 +1,47 @@ +import type { ImportSpec } from "../types"; + +export interface ImportStyle { + groupSeparator: boolean; + groupOrder: string[]; + sorted: boolean; + typeImportStyle?: "separate" | "inline"; + quoteStyle?: "single" | "double"; + semicolons?: boolean; +} + +export interface ParsedImport { + raw: string; + source: string; + names: string[]; + defaultImport?: string; + namespaceImport?: string; + typeOnly?: boolean; + assertion?: string; + system?: boolean; + alias?: string; +} + +export interface ImportRegion { + startLine: number; + endLine: number; + imports: ParsedImport[]; + style: ImportStyle; +} + +export interface ImportApplyResult { + content: string; + added: string[]; + warnings: string[]; +} + +export interface ImportHandler { + parseExisting(content: string): ImportRegion | null; + specToLine(spec: ImportSpec, style: ImportStyle): string; + apply(content: string, specs: ImportSpec[]): ImportApplyResult; +} + +export const defaultImportStyle: ImportStyle = { + groupSeparator: true, + groupOrder: [], + sorted: true, +}; diff --git a/packages/coding-agent/src/patch/imports/typescript.ts b/packages/coding-agent/src/patch/imports/typescript.ts new file mode 100644 index 000000000..fccbb9ac3 --- /dev/null +++ b/packages/coding-agent/src/patch/imports/typescript.ts @@ -0,0 +1,1150 @@ +import type { ImportSpec } from "../types"; +import { + defaultImportStyle, + type ImportHandler, + type ImportRegion, + type ImportStyle, + type ParsedImport, +} from "./types"; + +type ImportGroup = "side-effect" | "external" | "local"; +type QuoteStyle = "single" | "double"; +type TypeImportStyle = "separate" | "inline"; + +interface ImportedBinding { + imported: string; + local?: string; + typeOnly: boolean; + order: number; +} + +interface TypeScriptImportStyle extends ImportStyle { + typeImportStyle: TypeImportStyle; + quoteStyle: QuoteStyle; + semicolons: boolean; +} + +interface ParsedTypeScriptImport extends ParsedImport { + kind: "side-effect" | "binding"; + typeOnly: boolean; + order: number; + startLine: number; + endLine: number; + group: ImportGroup; + quoteStyle: QuoteStyle; + hasSemicolon: boolean; + namedBindings: ImportedBinding[]; + assertion?: string; +} + +interface ParsedTypeScriptRegion extends ImportRegion { + imports: ParsedTypeScriptImport[]; + style: TypeScriptImportStyle; +} + +interface ParsedImportStatement { + kind: "side-effect" | "binding"; + typeOnly: boolean; + source: string; + quoteStyle: QuoteStyle; + assertion?: string; + defaultImport?: string; + namespaceImport?: string; + namedBindings: ImportedBinding[]; +} + +interface ParsedSpecifierName { + imported: string; + local?: string; +} + +interface ParsedSpecRequest { + source: string; + defaultImport?: string; + namespaceImport?: string; + valueBindings: ImportedBinding[]; + typeBindings: ImportedBinding[]; + assertion?: string; +} + +const DEFAULT_GROUP_ORDER: ImportGroup[] = ["side-effect", "external", "local"]; +const ASSET_IMPORT_TYPES: Record = { + ".adoc": "text", + ".css": "css", + ".json": "json", + ".md": "text", + ".txt": "text", +}; + +function getTypeScriptStyle(style: ImportStyle): TypeScriptImportStyle { + const maybeStyle = style as Partial; + return { + ...style, + typeImportStyle: maybeStyle.typeImportStyle ?? "inline", + quoteStyle: maybeStyle.quoteStyle ?? "double", + semicolons: maybeStyle.semicolons ?? true, + }; +} + +function detectEol(content: string): string { + return content.includes("\r\n") ? "\r\n" : "\n"; +} + +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +function isLineComment(line: string): boolean { + return line.trimStart().startsWith("//"); +} + +function startsBlockComment(line: string): boolean { + return line.trimStart().startsWith("/*"); +} + +function lineStartsImport(line: string): boolean { + return line.trimStart().startsWith("import "); +} + +function isImportGroup(value: string): value is ImportGroup { + return value === "side-effect" || value === "external" || value === "local"; +} + +function classifyGroup(record: Pick): ImportGroup { + if (record.kind === "side-effect") return "side-effect"; + if (record.source.startsWith(".") || record.source.startsWith("/")) return "local"; + return "external"; +} + +function findPrefixEnd(lines: string[]): number { + let index = 0; + if (lines[0]?.startsWith("#!")) index += 1; + while (index < lines.length) { + const line = lines[index] ?? ""; + if (isBlankLine(line) || isLineComment(line)) { + index += 1; + continue; + } + if (startsBlockComment(line)) { + index += 1; + while (index < lines.length && !(lines[index - 1] ?? "").includes("*/")) index += 1; + continue; + } + break; + } + return index; +} + +function stripComments(text: string): string { + let result = ""; + let index = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let inBlockComment = false; + let escaped = false; + while (index < text.length) { + const char = text[index] ?? ""; + const next = text[index + 1] ?? ""; + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + index += 2; + continue; + } + if (char === "\n" || char === "\r") result += char; + index += 1; + continue; + } + if (inSingle) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "'") { + inSingle = false; + } + index += 1; + continue; + } + if (inDouble) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inDouble = false; + } + index += 1; + continue; + } + if (inTemplate) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "`") { + inTemplate = false; + } + index += 1; + continue; + } + if (char === "/" && next === "/") { + index += 2; + while (index < text.length) { + const commentChar = text[index] ?? ""; + if (commentChar === "\n" || commentChar === "\r") break; + index += 1; + } + continue; + } + if (char === "/" && next === "*") { + inBlockComment = true; + index += 2; + continue; + } + result += char; + if (char === "'") inSingle = true; + else if (char === '"') inDouble = true; + else if (char === "`") inTemplate = true; + index += 1; + } + return result; +} + +function parseStringLiteral(text: string): { value: string; quoteStyle: QuoteStyle; nextIndex: number } | null { + const quote = text[0]; + if (quote !== '"' && quote !== "'") return null; + let value = ""; + let index = 1; + let escaped = false; + while (index < text.length) { + const char = text[index] ?? ""; + if (escaped) { + value += char; + escaped = false; + index += 1; + continue; + } + if (char === "\\") { + value += char; + escaped = true; + index += 1; + continue; + } + if (char === quote) { + return { + value, + quoteStyle: quote === "'" ? "single" : "double", + nextIndex: index + 1, + }; + } + value += char; + index += 1; + } + return null; +} + +function scanBalancedState(text: string): { balanced: boolean } { + let braceDepth = 0; + let bracketDepth = 0; + let parenDepth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let inBlockComment = false; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index] ?? ""; + const next = text[index + 1] ?? ""; + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + if (inSingle) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "'") inSingle = false; + continue; + } + if (inDouble) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inDouble = false; + continue; + } + if (inTemplate) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "`") inTemplate = false; + continue; + } + if (char === "/" && next === "*") { + inBlockComment = true; + index += 1; + continue; + } + if (char === "/" && next === "/") break; + if (char === "'") { + inSingle = true; + continue; + } + if (char === '"') { + inDouble = true; + continue; + } + if (char === "`") { + inTemplate = true; + continue; + } + if (char === "{") braceDepth += 1; + else if (char === "}") braceDepth -= 1; + else if (char === "[") bracketDepth += 1; + else if (char === "]") bracketDepth -= 1; + else if (char === "(") parenDepth += 1; + else if (char === ")") parenDepth -= 1; + } + return { + balanced: + !inSingle && + !inDouble && + !inTemplate && + !inBlockComment && + braceDepth === 0 && + bracketDepth === 0 && + parenDepth === 0, + }; +} + +function findKeywordOutside(text: string, keyword: string): number { + let braceDepth = 0; + let bracketDepth = 0; + let parenDepth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let escaped = false; + for (let index = 0; index <= text.length - keyword.length; index += 1) { + const char = text[index] ?? ""; + if (inSingle) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "'") inSingle = false; + continue; + } + if (inDouble) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inDouble = false; + continue; + } + if (inTemplate) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "`") inTemplate = false; + continue; + } + if (char === "'") { + inSingle = true; + continue; + } + if (char === '"') { + inDouble = true; + continue; + } + if (char === "`") { + inTemplate = true; + continue; + } + if (char === "{") { + braceDepth += 1; + continue; + } + if (char === "}") { + braceDepth -= 1; + continue; + } + if (char === "[") { + bracketDepth += 1; + continue; + } + if (char === "]") { + bracketDepth -= 1; + continue; + } + if (char === "(") { + parenDepth += 1; + continue; + } + if (char === ")") { + parenDepth -= 1; + continue; + } + if (braceDepth !== 0 || bracketDepth !== 0 || parenDepth !== 0) continue; + if (text.slice(index, index + keyword.length) !== keyword) continue; + const before = index === 0 ? "" : (text[index - 1] ?? ""); + const after = text[index + keyword.length] ?? ""; + const beforeOk = before.length === 0 || /\s|,|\{|\}/.test(before); + const afterOk = after.length === 0 || /\s|['"]/.test(after); + if (beforeOk && afterOk) return index; + } + return -1; +} + +function splitTopLevel(text: string, delimiter: string): string[] { + const parts: string[] = []; + let current = ""; + let braceDepth = 0; + let bracketDepth = 0; + let parenDepth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index] ?? ""; + if (inSingle) { + current += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "'") inSingle = false; + continue; + } + if (inDouble) { + current += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inDouble = false; + continue; + } + if (inTemplate) { + current += char; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === "`") inTemplate = false; + continue; + } + if (char === "'") { + inSingle = true; + current += char; + continue; + } + if (char === '"') { + inDouble = true; + current += char; + continue; + } + if (char === "`") { + inTemplate = true; + current += char; + continue; + } + if (char === "{") braceDepth += 1; + else if (char === "}") braceDepth -= 1; + else if (char === "[") bracketDepth += 1; + else if (char === "]") bracketDepth -= 1; + else if (char === "(") parenDepth += 1; + else if (char === ")") parenDepth -= 1; + if (char === delimiter && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts; +} + +function parseNamedBinding(token: string, order: number, impliedTypeOnly: boolean): ImportedBinding | null { + const trimmed = token.trim(); + if (trimmed.length === 0) return null; + const hasInlineType = trimmed.startsWith("type "); + const typeOnly = impliedTypeOnly || hasInlineType; + const body = hasInlineType ? trimmed.slice(5).trim() : trimmed; + const match = /^(?[A-Za-z_$][\w$]*)(?:\s+as\s+(?[A-Za-z_$][\w$]*))?$/.exec(body); + if (!match?.groups?.imported) return null; + return { + imported: match.groups.imported, + local: match.groups.local, + typeOnly, + order, + }; +} + +function parseSpecifierName(token: string): ParsedSpecifierName | null { + const trimmed = token.trim(); + if (trimmed.length === 0) return null; + const match = /^(?[A-Za-z_$][\w$]*)(?:\s+as\s+(?[A-Za-z_$][\w$]*))?$/.exec(trimmed); + if (!match?.groups?.imported) return null; + return { + imported: match.groups.imported, + local: match.groups.local, + }; +} + +function parseClause(clauseText: string): Omit { + let clause = clauseText.trim(); + let typeOnly = false; + if (clause.startsWith("type ")) { + typeOnly = true; + clause = clause.slice(5).trim(); + } + let defaultImport: string | undefined; + let namespaceImport: string | undefined; + const namedBindings: ImportedBinding[] = []; + const parts = splitTopLevel(clause, ",") + .map(part => part.trim()) + .filter(part => part.length > 0); + let bindingOrder = 0; + if (parts.length === 1) { + const only = parts[0] ?? ""; + if (only.startsWith("{")) { + const inner = only.slice(1, only.endsWith("}") ? -1 : undefined); + for (const token of splitTopLevel(inner, ",")) { + const parsed = parseNamedBinding(token, bindingOrder, typeOnly); + if (!parsed) continue; + namedBindings.push(parsed); + bindingOrder += 1; + } + } else if (only.startsWith("* as ")) { + namespaceImport = only.slice(5).trim(); + } else { + defaultImport = only; + } + } else if (parts.length >= 2) { + defaultImport = parts[0] ?? undefined; + const second = parts[1] ?? ""; + if (second.startsWith("* as ")) { + namespaceImport = second.slice(5).trim(); + } else if (second.startsWith("{")) { + const inner = second.slice(1, second.endsWith("}") ? -1 : undefined); + for (const token of splitTopLevel(inner, ",")) { + const parsed = parseNamedBinding(token, bindingOrder, typeOnly); + if (!parsed) continue; + namedBindings.push(parsed); + bindingOrder += 1; + } + } + } + return { + typeOnly, + defaultImport, + namespaceImport, + namedBindings, + }; +} + +function parseImportStatementText(statement: string): ParsedImportStatement | null { + const withoutComments = stripComments(statement).trim(); + if (!withoutComments.startsWith("import ")) return null; + const normalized = withoutComments.endsWith(";") ? withoutComments.slice(0, -1).trimEnd() : withoutComments; + const remainder = normalized.slice(6).trimStart(); + const sideEffectLiteral = parseStringLiteral(remainder); + if (sideEffectLiteral) { + const assertionText = remainder.slice(sideEffectLiteral.nextIndex).trim(); + return { + kind: "side-effect", + typeOnly: false, + source: sideEffectLiteral.value, + quoteStyle: sideEffectLiteral.quoteStyle, + assertion: assertionText.startsWith("with") ? assertionText : undefined, + namedBindings: [], + }; + } + const fromIndex = findKeywordOutside(remainder, "from"); + if (fromIndex === -1) return null; + const clause = remainder.slice(0, fromIndex).trim(); + const sourceSection = remainder.slice(fromIndex + 4).trimStart(); + const sourceLiteral = parseStringLiteral(sourceSection); + if (!sourceLiteral) return null; + const assertionText = sourceSection.slice(sourceLiteral.nextIndex).trim(); + const parsedClause = parseClause(clause); + return { + kind: "binding", + typeOnly: parsedClause.typeOnly, + source: sourceLiteral.value, + quoteStyle: sourceLiteral.quoteStyle, + assertion: assertionText.startsWith("with") ? assertionText : undefined, + defaultImport: parsedClause.defaultImport, + namespaceImport: parsedClause.namespaceImport, + namedBindings: parsedClause.namedBindings, + }; +} + +function collectImportStatement( + lines: string[], + startIndex: number, +): { statement: string; nextIndex: number; endLine: number; hasSemicolon: boolean } | null { + const collected: string[] = []; + for (let index = startIndex; index < lines.length; index += 1) { + collected.push(lines[index] ?? ""); + const joined = collected.join("\n"); + const { balanced } = scanBalancedState(joined); + if (!balanced) continue; + const parsed = parseImportStatementText(joined); + if (!parsed) continue; + const trimmed = stripComments(joined).trimEnd(); + return { + statement: joined, + nextIndex: index + 1, + endLine: index + 1, + hasSemicolon: trimmed.endsWith(";"), + }; + } + return null; +} + +function hasBlankLineBetween(lines: string[], startIndex: number, endIndex: number): boolean { + for (let index = startIndex; index < endIndex; index += 1) { + if (isBlankLine(lines[index] ?? "")) return true; + } + return false; +} + +function inferStyle(records: ParsedTypeScriptImport[], lines: string[]): TypeScriptImportStyle { + const singleQuotes = records.filter(record => record.quoteStyle === "single").length; + const semicolons = records.filter(record => record.hasSemicolon).length; + const separateTypeStatements = records.filter(record => record.kind === "binding" && record.typeOnly).length; + const inlineTypeBindings = records.reduce((count, record) => { + if (record.kind !== "binding" || record.typeOnly) return count; + return count + record.namedBindings.filter(binding => binding.typeOnly).length; + }, 0); + const groupOrder = records.reduce((order, record) => { + if (!order.includes(record.group)) order.push(record.group); + return order; + }, []); + let groupSeparator = false; + for (let index = 1; index < records.length; index += 1) { + if (hasBlankLineBetween(lines, records[index - 1]!.endLine, records[index]!.startLine - 1)) { + groupSeparator = true; + break; + } + } + return { + ...defaultImportStyle, + groupSeparator, + groupOrder: groupOrder.length > 0 ? groupOrder : [...DEFAULT_GROUP_ORDER], + sorted: true, + typeImportStyle: separateTypeStatements > inlineTypeBindings ? "separate" : "inline", + quoteStyle: singleQuotes > records.length - singleQuotes ? "single" : "double", + semicolons: semicolons >= Math.ceil(records.length / 2), + }; +} + +function normalizeAssertion(assertion: string | undefined): string | undefined { + return assertion?.trim() || undefined; +} + +function cloneBinding(binding: ImportedBinding, order: number): ImportedBinding { + return { + imported: binding.imported, + local: binding.local, + typeOnly: binding.typeOnly, + order, + }; +} + +function cloneRecord(record: ParsedTypeScriptImport, order: number): ParsedTypeScriptImport { + return { + ...record, + order, + startLine: 0, + endLine: 0, + namedBindings: record.namedBindings.map((binding, index) => cloneBinding(binding, index)), + }; +} + +function compareBindings(left: ImportedBinding, right: ImportedBinding): number { + const leftName = `${left.typeOnly ? "1" : "0"}:${left.imported}:${left.local ?? left.imported}`; + const rightName = `${right.typeOnly ? "1" : "0"}:${right.imported}:${right.local ?? right.imported}`; + return leftName.localeCompare(rightName); +} + +function compareRecords(left: ParsedTypeScriptImport, right: ParsedTypeScriptImport): number { + if (left.group !== right.group) return left.group.localeCompare(right.group); + if (left.source !== right.source) return left.source.localeCompare(right.source); + if (left.kind !== right.kind) return left.kind === "side-effect" ? -1 : 1; + if (left.kind === "binding" && right.kind === "binding" && left.typeOnly !== right.typeOnly) + return left.typeOnly ? 1 : -1; + return left.order - right.order; +} + +function formatBinding(binding: ImportedBinding, statementTypeOnly = false): string { + const prefix = !statementTypeOnly && binding.typeOnly ? "type " : ""; + const localPart = binding.local ? ` as ${binding.local}` : ""; + return `${prefix}${binding.imported}${localPart}`; +} + +function formatSource(source: string, quoteStyle: QuoteStyle): string { + const quote = quoteStyle === "single" ? "'" : '"'; + return `${quote}${source}${quote}`; +} + +function renderAssertion(assertion: string | undefined): string { + return assertion ? ` ${assertion}` : ""; +} + +function renderRecord(record: ParsedTypeScriptImport, style: TypeScriptImportStyle): string { + const semicolon = style.semicolons ? ";" : ""; + if (record.kind === "side-effect") { + return `import ${formatSource(record.source, style.quoteStyle)}${renderAssertion(record.assertion)}${semicolon}`; + } + const namedBindings = [...record.namedBindings]; + if (style.sorted) namedBindings.sort(compareBindings); + const namedPart = + namedBindings.length > 0 + ? `{ ${namedBindings.map(binding => formatBinding(binding, record.typeOnly)).join(", ")} }` + : ""; + const defaultPart = record.defaultImport ?? ""; + const namespacePart = record.namespaceImport ? `* as ${record.namespaceImport}` : ""; + const bindingParts = [defaultPart, namespacePart, namedPart].filter(part => part.length > 0); + const typePrefix = record.typeOnly ? "type " : ""; + return `import ${typePrefix}${bindingParts.join(", ")} from ${formatSource(record.source, style.quoteStyle)}${renderAssertion(record.assertion)}${semicolon}`; +} + +function renderImportBlock(records: ParsedTypeScriptImport[], style: TypeScriptImportStyle): string[] { + const groups = new Map(); + for (const record of records) { + const list = groups.get(record.group) ?? []; + list.push(record); + groups.set(record.group, list); + } + const orderedGroups = [...style.groupOrder.filter(isImportGroup), ...DEFAULT_GROUP_ORDER].filter( + (group, index, values) => values.indexOf(group) === index, + ); + const lines: string[] = []; + for (const group of orderedGroups) { + const recordsInGroup = groups.get(group); + if (!recordsInGroup || recordsInGroup.length === 0) continue; + const ordered = [...recordsInGroup]; + if (style.sorted) ordered.sort(compareRecords); + else ordered.sort((left, right) => left.order - right.order); + for (const record of ordered) lines.push(renderRecord(record, style)); + if (style.groupSeparator) lines.push(""); + } + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +function deriveAssertion(source: string): string | undefined { + for (const [extension, type] of Object.entries(ASSET_IMPORT_TYPES)) { + if (!source.toLowerCase().endsWith(extension)) continue; + return `with { type: ${JSON.stringify(type)} }`; + } + return undefined; +} + +function parseImportSpecName(token: string, order: number, typeOnly: boolean): ImportedBinding | null { + const parsed = parseSpecifierName(token); + if (!parsed) return null; + return { + imported: parsed.imported, + local: parsed.local, + typeOnly, + order, + }; +} + +function specToRequest(spec: ImportSpec): ParsedSpecRequest { + const valueBindings: ImportedBinding[] = []; + const typeBindings: ImportedBinding[] = []; + let order = 0; + for (const imported of spec.imports ?? []) { + const trimmed = imported.trim(); + if (trimmed.length === 0) continue; + const isTypeOnly = trimmed.startsWith("type "); + const parsed = parseImportSpecName(isTypeOnly ? trimmed.slice(5).trim() : trimmed, order, isTypeOnly); + if (!parsed) continue; + if (parsed.typeOnly) typeBindings.push(parsed); + else valueBindings.push(parsed); + order += 1; + } + return { + source: spec.from, + defaultImport: spec.default, + namespaceImport: spec.namespace, + valueBindings, + typeBindings, + assertion: deriveAssertion(spec.from), + }; +} + +function requestToRecords( + request: ParsedSpecRequest, + style: TypeScriptImportStyle, + startOrder: number, +): ParsedTypeScriptImport[] { + const records: ParsedTypeScriptImport[] = []; + let order = startOrder; + const group = classifyGroup({ + kind: + request.defaultImport || + request.namespaceImport || + request.valueBindings.length > 0 || + request.typeBindings.length > 0 + ? "binding" + : "side-effect", + source: request.source, + }); + if ( + !request.defaultImport && + !request.namespaceImport && + request.valueBindings.length === 0 && + request.typeBindings.length === 0 + ) { + records.push({ + raw: "", + source: request.source, + names: [], + kind: "side-effect", + typeOnly: false, + order, + startLine: 0, + endLine: 0, + group, + quoteStyle: style.quoteStyle, + hasSemicolon: style.semicolons, + namedBindings: [], + assertion: request.assertion, + }); + return records; + } + if (request.defaultImport || request.namespaceImport || request.valueBindings.length > 0) { + const bindings = request.valueBindings.map((binding, index) => cloneBinding(binding, index)); + if (style.typeImportStyle === "inline") { + for (const typeBinding of request.typeBindings) bindings.push(cloneBinding(typeBinding, bindings.length)); + } + records.push({ + raw: "", + source: request.source, + names: bindings.map(binding => formatBinding(binding, false)), + defaultImport: request.defaultImport, + namespaceImport: request.namespaceImport, + kind: "binding", + typeOnly: false, + order, + startLine: 0, + endLine: 0, + group, + quoteStyle: style.quoteStyle, + hasSemicolon: style.semicolons, + namedBindings: bindings, + assertion: request.assertion, + }); + order += 1; + } + if ( + request.typeBindings.length > 0 && + (style.typeImportStyle === "separate" || + (!request.defaultImport && !request.namespaceImport && request.valueBindings.length === 0)) + ) { + const bindings = request.typeBindings.map((binding, index) => + cloneBinding({ ...binding, typeOnly: false }, index), + ); + records.push({ + raw: "", + source: request.source, + names: bindings.map(binding => formatBinding(binding, true)), + kind: "binding", + typeOnly: true, + order, + startLine: 0, + endLine: 0, + group, + quoteStyle: style.quoteStyle, + hasSemicolon: style.semicolons, + namedBindings: bindings, + assertion: request.assertion, + }); + } + return records; +} + +function bindingKey(binding: ImportedBinding, statementTypeOnly = false): string { + const typeKey = statementTypeOnly || binding.typeOnly ? "type" : "value"; + return `${typeKey}:${binding.imported}:${binding.local ?? binding.imported}`; +} + +function hasBinding( + records: ParsedTypeScriptImport[], + source: string, + binding: ImportedBinding, + statementTypeOnly = false, +): boolean { + return records.some( + record => + record.kind === "binding" && + record.source === source && + record.namedBindings.some( + existing => bindingKey(existing, record.typeOnly) === bindingKey(binding, statementTypeOnly), + ), + ); +} + +function getSourceRecords(records: ParsedTypeScriptImport[], source: string): ParsedTypeScriptImport[] { + return records.filter(record => record.source === source); +} + +function getBindingRecord( + records: ParsedTypeScriptImport[], + source: string, + typeOnly: boolean, +): ParsedTypeScriptImport | undefined { + return records.find(record => record.kind === "binding" && record.source === source && record.typeOnly === typeOnly); +} + +function syncNames(record: ParsedTypeScriptImport): void { + record.names = record.namedBindings.map(binding => formatBinding(binding, record.typeOnly)); +} + +function mergeNamedBindings( + target: ParsedTypeScriptImport, + bindings: ImportedBinding[], + typeOnly: boolean, + style: TypeScriptImportStyle, +): ImportedBinding[] { + const added: ImportedBinding[] = []; + for (const binding of bindings) { + const candidate = typeOnly + ? { ...binding, typeOnly: style.typeImportStyle === "inline" && !target.typeOnly } + : binding; + const existing = target.namedBindings.some( + current => bindingKey(current, target.typeOnly) === bindingKey(candidate, target.typeOnly), + ); + if (existing) continue; + target.namedBindings.push(cloneBinding(candidate, target.namedBindings.length)); + added.push(candidate); + } + if (style.sorted) target.namedBindings.sort(compareBindings); + syncNames(target); + return added; +} + +function buildPreviewRecord( + base: ParsedTypeScriptImport, + parts: { defaultImport?: string; namespaceImport?: string; namedBindings?: ImportedBinding[]; typeOnly?: boolean }, +): ParsedTypeScriptImport { + const namedBindings = parts.namedBindings?.map((binding, index) => cloneBinding(binding, index)) ?? []; + return { + ...base, + defaultImport: parts.defaultImport, + namespaceImport: parts.namespaceImport, + namedBindings, + names: namedBindings.map(binding => formatBinding(binding, parts.typeOnly ?? base.typeOnly)), + typeOnly: parts.typeOnly ?? base.typeOnly, + }; +} + +function mergeRecords( + existing: ParsedTypeScriptImport[], + requested: ParsedTypeScriptImport[], + style: TypeScriptImportStyle, +): { records: ParsedTypeScriptImport[]; added: string[] } { + const records = existing.map((record, index) => cloneRecord(record, index)); + const added: string[] = []; + let nextOrder = records.reduce((max, record) => Math.max(max, record.order), -1) + 1; + for (const record of requested) { + if (record.kind === "side-effect") { + const exists = records.some( + existingRecord => existingRecord.kind === "side-effect" && existingRecord.source === record.source, + ); + if (exists) continue; + const inserted = cloneRecord(record, nextOrder); + records.push(inserted); + added.push(renderRecord(inserted, style)); + nextOrder += 1; + continue; + } + if (record.typeOnly && style.typeImportStyle !== "separate") { + const inlineTarget = getBindingRecord(records, record.source, false); + if (inlineTarget) { + const normalizedBindings = record.namedBindings.map(binding => ({ ...binding, typeOnly: true })); + const missingBindings = normalizedBindings.filter(binding => !hasBinding(records, record.source, binding)); + if (missingBindings.length === 0) continue; + mergeNamedBindings(inlineTarget, normalizedBindings, true, style); + added.push( + renderRecord( + buildPreviewRecord(inlineTarget, { namedBindings: missingBindings, typeOnly: false }), + style, + ), + ); + continue; + } + } + const target = getBindingRecord(records, record.source, record.typeOnly); + if (!target) { + const inserted = cloneRecord(record, nextOrder); + records.push(inserted); + added.push(renderRecord(inserted, style)); + nextOrder += 1; + continue; + } + const addedDefault = + record.defaultImport && + !getSourceRecords(records, record.source).some( + sourceRecord => sourceRecord.defaultImport === record.defaultImport, + ) + ? record.defaultImport + : undefined; + if (addedDefault) target.defaultImport = addedDefault; + const addedNamespace = + record.namespaceImport && + !getSourceRecords(records, record.source).some( + sourceRecord => sourceRecord.namespaceImport === record.namespaceImport, + ) + ? record.namespaceImport + : undefined; + if (addedNamespace) target.namespaceImport = addedNamespace; + const missingBindings = record.namedBindings.filter( + binding => !hasBinding(records, record.source, binding, record.typeOnly), + ); + if (missingBindings.length > 0) mergeNamedBindings(target, missingBindings, record.typeOnly, style); + if (!addedDefault && !addedNamespace && missingBindings.length === 0) continue; + added.push( + renderRecord( + buildPreviewRecord(target, { + defaultImport: addedDefault, + namespaceImport: addedNamespace, + namedBindings: missingBindings, + typeOnly: record.typeOnly, + }), + style, + ), + ); + } + return { records, added }; +} + +function parseTypeScriptRegion(content: string): ParsedTypeScriptRegion | null { + const lines = content.split(/\r?\n/); + const prefixEnd = findPrefixEnd(lines); + let index = prefixEnd; + while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; + if (!lineStartsImport(lines[index] ?? "")) return null; + const records: ParsedTypeScriptImport[] = []; + while (index < lines.length) { + const line = lines[index] ?? ""; + if (isBlankLine(line) || isLineComment(line)) { + index += 1; + continue; + } + if (startsBlockComment(line)) { + index += 1; + while (index < lines.length && !(lines[index - 1] ?? "").includes("*/")) index += 1; + continue; + } + if (!lineStartsImport(line)) break; + const collected = collectImportStatement(lines, index); + if (!collected) break; + const parsed = parseImportStatementText(collected.statement); + if (!parsed) break; + records.push({ + raw: collected.statement, + source: parsed.source, + names: parsed.namedBindings.map(binding => formatBinding(binding, parsed.typeOnly)), + defaultImport: parsed.defaultImport, + namespaceImport: parsed.namespaceImport, + typeOnly: parsed.typeOnly, + assertion: normalizeAssertion(parsed.assertion), + kind: parsed.kind, + order: records.length, + startLine: index + 1, + endLine: collected.endLine, + group: classifyGroup({ kind: parsed.kind, source: parsed.source }), + quoteStyle: parsed.quoteStyle, + hasSemicolon: collected.hasSemicolon, + namedBindings: parsed.namedBindings.map((binding, bindingIndex) => cloneBinding(binding, bindingIndex)), + }); + index = collected.nextIndex; + } + if (records.length === 0) return null; + return { + startLine: records[0]!.startLine, + endLine: Math.max(...records.map(record => record.endLine)), + imports: records, + style: inferStyle(records, lines), + }; +} + +function parseExisting(content: string): ImportRegion | null { + return parseTypeScriptRegion(content); +} + +function specToLine(spec: ImportSpec, style: ImportStyle): string { + const tsStyle = getTypeScriptStyle(style); + const request = specToRequest(spec); + const records = requestToRecords(request, tsStyle, 0); + return records.map(record => renderRecord(record, tsStyle)).join("\n"); +} + +function insertWithoutRegion(content: string, lines: string[], blockLines: string[], eol: string): string { + if (content.length === 0) return blockLines.join(eol); + const insertAt = findPrefixEnd(lines); + const before = lines.slice(0, insertAt); + const after = lines.slice(insertAt); + const needsTrailingBlank = + after.length > 0 && after.some(line => line.trim().length > 0) && !isBlankLine(after[0] ?? ""); + const nextLines = [...before, ...blockLines, ...(needsTrailingBlank ? [""] : []), ...after]; + const nextContent = nextLines.join(eol); + if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; + return nextContent; +} + +function replaceRegion( + content: string, + lines: string[], + region: ParsedTypeScriptRegion, + blockLines: string[], + eol: string, +): string { + const nextLines = [...lines.slice(0, region.startLine - 1), ...blockLines, ...lines.slice(region.endLine)]; + const nextContent = nextLines.join(eol); + if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; + return nextContent; +} + +export const typescriptImportHandler: ImportHandler = { + parseExisting, + specToLine, + apply(content, specs) { + const region = parseTypeScriptRegion(content); + const style = getTypeScriptStyle( + region?.style ?? { + ...defaultImportStyle, + groupSeparator: true, + groupOrder: [...DEFAULT_GROUP_ORDER], + sorted: true, + typeImportStyle: "inline", + quoteStyle: "double", + semicolons: true, + }, + ); + const requested: ParsedTypeScriptImport[] = []; + let order = 0; + for (const spec of specs) { + const request = specToRequest(spec); + const records = requestToRecords(request, style, order); + requested.push(...records); + order += Math.max(records.length, 1); + } + if (requested.length === 0) return { content, added: [], warnings: [] }; + const { records, added } = mergeRecords(region?.imports ?? [], requested, style); + if (added.length === 0) return { content, added: [], warnings: [] }; + const eol = detectEol(content); + const lines = content.split(/\r?\n/); + const blockLines = renderImportBlock(records, style); + const nextContent = region + ? replaceRegion(content, lines, region, blockLines, eol) + : insertWithoutRegion(content, lines, blockLines, eol); + return { content: nextContent, added, warnings: [] }; + }, +}; + +export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 0b47ee05f..a80089173 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -44,10 +44,11 @@ import { type HashlineEdit, parseTag, } from "./hashline"; +// Internal imports +import { applyImports } from "./imports"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; -// Internal imports -import type { FileSystem, Operation, PatchInput } from "./types"; +import type { FileSystem, ImportSpec, Operation, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -63,6 +64,7 @@ export * from "./diff"; export * from "./fuzzy"; // Hashline export * from "./hashline"; +export * from "./imports"; // Normalization export * from "./normalize"; // Parsing @@ -76,14 +78,44 @@ export * from "./types"; // Schemas // ═══════════════════════════════════════════════════════════════════════════ -const replaceEditSchema = Type.Object({ +const importSpecSchema = Type.Object( + { + from: Type.String({ description: "Module/crate/package path" }), + imports: Type.Optional( + Type.Array(Type.String(), { + description: "Named imports. Prefix TS type imports with `type ` (for example `type FC`)", + }), + ), + default: Type.Optional(Type.String({ description: "Default import name (TS/JS)" })), + namespace: Type.Optional(Type.String({ description: "Namespace import name (TS/JS)" })), + alias: Type.Optional(Type.String({ description: "Import alias (Go/Python)" })), + system: Type.Optional(Type.Boolean({ description: "System include for C/C++ headers" })), + }, + { additionalProperties: false }, +); + +const replaceEditSchemaBase = Type.Object({ path: Type.String({ description: "File path (relative or absolute)" }), old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), new_text: Type.String({ description: "Replacement text" }), all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), }); -const patchEditSchema = Type.Object({ +const replaceEditSchemaWithImports = Type.Object( + { + ...replaceEditSchemaBase.properties, + imports: Type.Optional( + Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), + ), + }, + { additionalProperties: false }, +); + +function buildReplaceEditSchema(importsEnabled: boolean) { + return importsEnabled ? replaceEditSchemaWithImports : replaceEditSchemaBase; +} + +const patchEditSchemaBase = Type.Object({ path: Type.String({ description: "File path" }), op: Type.Optional( StringEnum(["create", "delete", "update"], { @@ -94,8 +126,22 @@ const patchEditSchema = Type.Object({ diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), }); -export type ReplaceParams = Static; -export type PatchParams = Static; +const patchEditSchemaWithImports = Type.Object( + { + ...patchEditSchemaBase.properties, + imports: Type.Optional( + Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), + ), + }, + { additionalProperties: false }, +); + +function buildPatchEditSchema(importsEnabled: boolean) { + return importsEnabled ? patchEditSchemaWithImports : patchEditSchemaBase; +} + +export type ReplaceParams = Static; +export type PatchParams = Static; /** * Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`. @@ -204,7 +250,7 @@ const hashlineEditSchema = Type.Object( { additionalProperties: false }, ); -const hashlineEditParamsSchema = Type.Object( +const hashlineEditParamsSchemaBase = Type.Object( { path: Type.String({ description: "path" }), edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), @@ -214,8 +260,22 @@ const hashlineEditParamsSchema = Type.Object( { additionalProperties: false }, ); +const hashlineEditParamsSchemaWithImports = Type.Object( + { + ...hashlineEditParamsSchemaBase.properties, + imports: Type.Optional( + Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), + ), + }, + { additionalProperties: false }, +); + +function buildHashlineEditParamsSchema(importsEnabled: boolean) { + return importsEnabled ? hashlineEditParamsSchemaWithImports : hashlineEditParamsSchemaBase; +} + export type HashlineToolEdit = Static; -export type HashlineParams = Static; +export type HashlineParams = Static; // ═══════════════════════════════════════════════════════════════════════════ // Resilient anchor resolution @@ -357,11 +417,31 @@ function mergeDiagnosticsWithWarnings( }; } +function applyRequestedImports( + filePath: string, + content: string, + imports: ImportSpec[] | undefined, + importsEnabled: boolean, +): { content: string; warnings: string[] } { + if (!importsEnabled || !imports || imports.length === 0) { + return { content, warnings: [] }; + } + + const result = applyImports(filePath, content, imports); + return { content: result.content, warnings: result.warnings }; +} + // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ -type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditParamsSchema; +type TInput = + | typeof replaceEditSchemaBase + | typeof replaceEditSchemaWithImports + | typeof patchEditSchemaBase + | typeof patchEditSchemaWithImports + | typeof hashlineEditParamsSchemaBase + | typeof hashlineEditParamsSchemaWithImports; export type EditMode = "replace" | "patch" | "hashline"; @@ -379,6 +459,15 @@ function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams): return "old_text" in params && "new_text" in params; } +function getRequestedImports(params: ReplaceParams | PatchParams | HashlineParams): ImportSpec[] | undefined { + if (!("imports" in params)) { + return undefined; + } + + const { imports } = params as { imports?: unknown }; + return Array.isArray(imports) ? (imports as ImportSpec[]) : undefined; +} + /** * Edit tool implementation. * @@ -393,8 +482,12 @@ export class EditTool implements AgentTool { readonly #allowFuzzy: boolean; readonly #fuzzyThreshold: number; + readonly #manageImports: boolean; readonly #writethrough: WritethroughCallback; readonly #editMode?: EditMode | null; + readonly #replaceSchema: TInput; + readonly #patchSchema: TInput; + readonly #hashlineSchema: TInput; constructor(private readonly session: ToolSession) { const { @@ -437,6 +530,10 @@ export class EditTool implements AgentTool { } break; } + this.#manageImports = session.settings.get("edit.manageImports"); + this.#replaceSchema = buildReplaceEditSchema(this.#manageImports); + this.#patchSchema = buildPatchEditSchema(this.#manageImports); + this.#hashlineSchema = buildHashlineEditParamsSchema(this.#manageImports); const enableLsp = session.enableLsp ?? true; const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); @@ -469,13 +566,14 @@ export class EditTool implements AgentTool { * Dynamic description based on current edit mode (which depends on current model). */ get description(): string { + const promptContext = { editManageImportsEnabled: this.#manageImports }; switch (this.mode) { case "patch": - return renderPromptTemplate(patchDescription); + return renderPromptTemplate(patchDescription, promptContext); case "hashline": - return renderPromptTemplate(hashlineDescription); + return renderPromptTemplate(hashlineDescription, promptContext); default: - return renderPromptTemplate(replaceDescription); + return renderPromptTemplate(replaceDescription, promptContext); } } @@ -485,11 +583,11 @@ export class EditTool implements AgentTool { get parameters(): TInput { switch (this.mode) { case "patch": - return patchEditSchema; + return this.#patchSchema; case "hashline": - return hashlineEditParamsSchema; + return this.#hashlineSchema; default: - return replaceEditSchema; + return this.#replaceSchema; } } @@ -510,6 +608,7 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for hashline mode."); } const { path, edits, delete: deleteFile, move } = params; + const imports = getRequestedImports(params); enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", move }); @@ -574,9 +673,13 @@ export class EditTool implements AgentTool { throw new Error(`File not found: ${path}`); } } - await fs.writeFile(absolutePath, lines.join("\n")); + const createdResult = applyRequestedImports(move ?? path, lines.join("\n"), imports, this.#manageImports); + await fs.writeFile(absolutePath, createdResult.content); + const warningsBlock = createdResult.warnings.length + ? `\n\nWarnings:\n${createdResult.warnings.join("\n")}` + : ""; return { - content: [{ type: "text", text: `Created ${path}` }], + content: [{ type: "text", text: `Created ${path}${warningsBlock}` }], details: { diff: "", op: "create", @@ -598,10 +701,11 @@ export class EditTool implements AgentTool { const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); normalizedText = anchorResult.lines; + const importResult = applyRequestedImports(move ?? path, normalizedText, imports, this.#manageImports); const result = { - text: normalizedText, + text: importResult.content, firstChangedLine: anchorResult.firstChangedLine, - warnings: anchorResult.warnings, + warnings: [...(anchorResult.warnings ?? []), ...importResult.warnings], noopEdits: anchorResult.noopEdits, }; if (originalNormalized === result.text && !move) { @@ -711,6 +815,7 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for patch mode."); } const { path, op: rawOp, rename, diff } = params; + const imports = getRequestedImports(params); // Normalize unrecognized operations to "update" const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; @@ -736,6 +841,36 @@ export class EditTool implements AgentTool { fuzzyThreshold: this.#fuzzyThreshold, allowFuzzy: this.#allowFuzzy, }); + const writePath = result.change.newPath ?? resolvedPath; + let importDiagnostics: FileDiagnosticsResult | undefined; + const importWarnings: string[] = []; + if ( + imports && + imports.length > 0 && + result.change.type !== "delete" && + result.change.newContent !== undefined + ) { + const importResult = applyRequestedImports( + result.change.newPath ?? path, + result.change.newContent, + imports, + this.#manageImports, + ); + importWarnings.push(...importResult.warnings); + if (importResult.content !== result.change.newContent) { + result.change.newContent = importResult.content; + importDiagnostics = await this.#writethrough( + writePath, + result.change.newContent, + signal, + Bun.file(writePath), + batchRequest, + ); + } else { + result.change.newContent = importResult.content; + } + } + if (resolvedRename) { invalidateFsScanAfterRename(resolvedPath, resolvedRename); } else if (result.change.type === "delete") { @@ -769,12 +904,12 @@ export class EditTool implements AgentTool { break; } - let diagnostics = fs.getDiagnostics(); + let diagnostics = importDiagnostics ?? fs.getDiagnostics(); if (op === "delete" && batchRequest?.flush) { const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); diagnostics ??= flushedDiagnostics; } - const patchWarnings = result.warnings ?? []; + const patchWarnings = [...(result.warnings ?? []), ...importWarnings]; const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); const meta = outputMeta() @@ -801,6 +936,7 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for replace mode."); } const { path, old_text, new_text, all } = params; + const imports = getRequestedImports(params); enforcePlanModeWrite(this.session, path); @@ -860,7 +996,8 @@ export class EditTool implements AgentTool { ); } - const finalContent = bom + restoreLineEndings(result.content, originalEnding); + const importResult = applyRequestedImports(path, result.content, imports, this.#manageImports); + const finalContent = bom + restoreLineEndings(importResult.content, originalEnding); const diagnostics = await this.#writethrough( absolutePath, finalContent, @@ -869,7 +1006,8 @@ export class EditTool implements AgentTool { batchRequest, ); invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, result.content); + const diffResult = generateDiffString(normalizedContent, importResult.content); + const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, importResult.warnings); const resultText = result.count > 1 @@ -877,12 +1015,17 @@ export class EditTool implements AgentTool { : `Successfully replaced text in ${path}.`; const meta = outputMeta() - .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) + .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: resultText }], - details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta }, + details: { + diff: diffResult.diff, + firstChangedLine: diffResult.firstChangedLine, + diagnostics: mergedDiagnostics, + meta, + }, }; } } diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index d612c3ff7..e848e8a2a 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -174,6 +174,26 @@ export interface ApplyPatchOptions { fs?: FileSystem; } +// ═══════════════════════════════════════════════════════════════════════════ +// Import Management Types +// ═══════════════════════════════════════════════════════════════════════════ + +/** Structured import request supplied alongside an edit operation. */ +export interface ImportSpec { + /** Module/crate/package path or header name */ + from: string; + /** Named imports; TS type imports use a `type ` prefix */ + imports?: string[]; + /** Default import name (TS/JS) */ + default?: string; + /** Namespace import name (TS/JS) */ + namespace?: string; + /** Import alias (Go/Python) */ + alias?: string; + /** Whether a C/C++ include is a system include */ + system?: boolean; +} + // ═══════════════════════════════════════════════════════════════════════════ // Diff Generation Types // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 1845dff40..cba7cb2f5 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -8,6 +8,16 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one - `move` — optional rename target - `delete` — optional whole-file delete - `edits` — array of `{ loc, content }` entries +{{#if editManageImportsEnabled}} +- `imports` — **OPTIONAL** import/include declarations to merge after the main edit +**Import entry** +- `from` — **REQUIRED** module/crate/package/header source +- `imports` — **OPTIONAL** named imports +- `default` — **OPTIONAL** default import name +- `namespace` — **OPTIONAL** namespace import name +- `alias` — **OPTIONAL** import alias where supported +- `system` — **OPTIONAL** system-include flag for C/C++ +{{/if}} **Edit entry**: `{ loc, content }` - `loc` — where to apply the edit (see below) @@ -17,7 +27,11 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one - `"append"` / `"prepend"` — insert at end/start of file - `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line - `{ range: { pos: "N#ID", end: "N#ID" } }` — replace inclusive range of lines `pos..end` with new content - + +{{#if editManageImportsEnabled}} +`imports` are merged after the main edit is applied. Use them when inserted or replaced code now depends on new imports/includes. +{{/if}} + All examples below reference the same file: @@ -42,6 +56,28 @@ All examples below reference the same file: {{hline 18 "}"}} ``` +{{#if editManageImportsEnabled}} + +Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. +``` +{ + path: "a.ts", + edits: [{ + loc: { range: { pos: {{href 15 "\t\tconsole.error(err);"}}, end: {{href 16 "\t\treturn null;"}} } }, + content: [ + "\t\tif (isEnoent(err)) return null;", + "\t\tthrow err;" + ] + }], + imports: [{ + from: "./errors", + imports: ["isEnoent"] + }] +} +``` +`imports` are merged after the anchored edit, so existing declarations are reused before new ones are inserted. + +{{else}} Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. ``` @@ -57,6 +93,7 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er } ``` +{{/if}} Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`. @@ -128,4 +165,4 @@ When adding a sibling declaration, prefer `prepend` on the next declaration. - For a range, either replace only the body or replace the whole range. Do not split range boundaries. - `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs. - You **MUST NOT** use this tool to reformat or clean up unrelated code. **ALWAYS** use project-specific tooling like linters or code formatters which are much more efficient and reliable. - + diff --git a/packages/coding-agent/src/prompts/tools/patch.md b/packages/coding-agent/src/prompts/tools/patch.md index 6ad8b460e..191c0bef4 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -14,6 +14,12 @@ Patches files given diff hunks. Primary tool for existing-file edits. **Context Lines:** Use enough ` `-prefixed lines to make match unique (usually 2–8) When editing structured blocks (nested braces, tags, indented regions), include opening and closing lines so edit stays inside block +{{#if editManageImportsEnabled}} +**Imports:** +- `imports` is **OPTIONAL**. Use it when the edit adds code that now requires new imports/includes. +- Each entry starts with `from` and **MAY** include named `imports`, `default`, `namespace`, `alias`, or `system`, depending on language. +- Import management runs after the main edit, so the diff should focus on the code change and `imports` should describe declarations to merge or add. +{{/if}} @@ -23,13 +29,22 @@ type T = // - Each hunk begins with "@@" (anchor optional). // - Each hunk body only has lines starting with ' ' | '+' | '-'. // - Each hunk includes at least one change (+ or -). - | { path: string, op: "update", diff: string } + | { path: string, op: "update", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } // Diff is full file content, no prefixes. - | { path: string, op: "create", diff: string } + | { path: string, op: "create", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } // No diff for delete. | { path: string, op: "delete" } - // New path for update+move. - | { path: string, op: "update", rename: string, diff: string } +{{#if editManageImportsEnabled}} + +type ImportSpec = { + from: string; + imports?: string[]; + default?: string; + namespace?: string; + alias?: string; + system?: boolean; +} +{{/if}} ``` @@ -49,24 +64,50 @@ Returns success/failure; on failure, error message indicates: - **NEVER** use edit to fix indentation, whitespace, or reformat code. Formatting is a single command run once at the end (`bun fmt`, `cargo fmt`, `prettier —write`, etc.)—not N individual edits. If you see inconsistent indentation after an edit, leave it; the formatter will fix all of it in one pass. - -edit {"path":"hello.txt","op":"create","diff":"Hello\n"} - - +{{#if editManageImportsEnabled}} -edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"} +```json +{ + "path": "src/app.ts", + "op": "update", + "diff": "@@ function run() {\n function run() {\n-\treturn value;\n+\treturn format(value);\n }\n", + "imports": [ + { + "from": "./format", + "imports": ["format"] + } + ] +} +``` +`imports` are merged after the diff applies, so existing imports are reused when possible and only missing declarations are added. - -edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"} - - - -edit {"path":"obsolete.txt","op":"delete"} + +```json +{ + "path": "src/app.ts", + "op": "update", + "diff": "@@\n ...\n", + "imports": [ + { + "from": "react", + "default": "React", + "imports": [ + "useMemo", + "type FC", + "useState" + ] + } + ] +} +``` +Use only supported fields: `from`, optional `imports`, `default`, `namespace`, `alias`, and `system`. +{{/if}} -- Generic anchors: `import`, `export`, `describe`, `function`, `const` -- Repeating same addition in multiple hunks (duplicate blocks) -- Full-file overwrites for minor changes (acceptable for major restructures or short files) +{{#if editManageImportsEnabled}} +- Do not duplicate imports already present in the file; describe the desired imports once and let merge logic dedupe. +- Do not rely on `imports` to perform unrelated code edits; it only manages import/include declarations after the main edit. +{{/if}} diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index eb6b13879..ff7d0e2bf 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -5,8 +5,34 @@ Performs string replacements in files with fuzzy whitespace matching. - If `old_text` not unique, you **MUST** expand to include more context or use `all: true` to replace all occurrences - Fuzzy matching handles minor whitespace/indentation differences automatically - You **SHOULD** prefer editing existing files over creating new ones +{{#if editManageImportsEnabled}} +- `imports` is **OPTIONAL**. Use it when the replacement introduces code that needs imports/includes. +- Each `imports` entry starts with `from` and **MAY** include named `imports`, `default`, `namespace`, `alias`, or `system`. +- Import management runs after the main replacement, so use `new_text` for the code change and `imports` for declarations to merge or add. +{{/if}} + +```ts +type T = { + path: string; + old_text: string; + new_text: string; + all?: boolean; +{{#if editManageImportsEnabled}} + imports?: Array<{ + from: string; + imports?: string[]; + default?: string; + namespace?: string; + alias?: string; + system?: boolean; + }>; +{{/if}} +}; +``` + + Returns success/failure status. On success, file modified in place with replacement applied. On failure (e.g., `old_text` not found or matches multiple locations without `all: true`), returns error describing issue. @@ -15,8 +41,44 @@ Returns success/failure status. On success, file modified in place with replacem - You **MUST** read the file at least once in the conversation before editing. Tool errors if you attempt edit without reading file first. +{{#if editManageImportsEnabled}} + +```json +{ + "path": "src/widget.ts", + "old_text": "return value;", + "new_text": "return formatValue(value);", + "imports": [ + { + "from": "./format", + "imports": ["formatValue"] + } + ] +} +``` +`imports` are merged after `new_text` is written, so existing imports stay canonical and only missing ones are added. + + + +```json +{ + "path": "src/widget.ts", + "old_text": "uuid()", + "new_text": "crypto.randomUUID()", + "imports": [ + { + "from": "node:crypto", + "namespace": "crypto" + } + ] +} +``` +Use only supported fields: `from`, optional `imports`, `default`, `namespace`, `alias`, and `system`. + +{{/if}} + -Replace for content-addressed changes—you identify \_what* to change by its text. +Replace for content-addressed changes—you identify _what_ to change by its text. For position-addressed or pattern-addressed changes, bash more efficient: diff --git a/packages/coding-agent/test/patch/imports/cpp.test.ts b/packages/coding-agent/test/patch/imports/cpp.test.ts new file mode 100644 index 000000000..34717c6e2 --- /dev/null +++ b/packages/coding-agent/test/patch/imports/cpp.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import { cppImportHandler } from "../../../src/patch/imports/cpp"; +import type { ImportSpec } from "../../../src/patch/types"; + +function apply(content: string, imports: ImportSpec[]) { + return cppImportHandler.apply(content, imports); +} + +describe("c and c++ import handler", () => { + it("inserts includes after #pragma once and keeps system includes before local includes", () => { + const source = ["#pragma once", "", "class Widget {};", ""].join("\n"); + + const result = apply(source, [ + { from: "vector", system: true }, + { from: "widget/detail.h", system: false }, + ]); + + expect(result.content).toBe( + ["#pragma once", "", "#include ", "", '#include "widget/detail.h"', "", "class Widget {};", ""].join( + "\n", + ), + ); + expect(result.added).toEqual(["#include ", '#include "widget/detail.h"']); + expect(result.warnings).toEqual([]); + }); + + it("inserts includes after a header guard", () => { + const source = ["#ifndef WIDGET_H", "#define WIDGET_H", "", "struct Widget {};", ""].join("\n"); + + const result = apply(source, [{ from: "widget/detail.h", system: false }]); + + expect(result.content).toBe( + ["#ifndef WIDGET_H", "#define WIDGET_H", "", '#include "widget/detail.h"', "", "struct Widget {};", ""].join( + "\n", + ), + ); + expect(result.added).toEqual(['#include "widget/detail.h"']); + expect(result.warnings).toEqual([]); + }); + + it("deduplicates requested includes while preserving grouped ordering", () => { + const source = ["#include ", "", '#include "app/foo.h"', "", "int main();", ""].join("\n"); + + const result = apply(source, [ + { from: "string", system: true }, + { from: "vector", system: true }, + { from: "app/foo.h", system: false }, + { from: "app/foo.h", system: false }, + { from: "app/bar.h", system: false }, + ]); + + expect(result.content).toBe( + [ + "#include ", + "#include ", + "", + '#include "app/bar.h"', + '#include "app/foo.h"', + "", + "int main();", + "", + ].join("\n"), + ); + expect(result.added).toEqual(["#include ", '#include "app/bar.h"']); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/patch/imports/go.test.ts b/packages/coding-agent/test/patch/imports/go.test.ts new file mode 100644 index 000000000..8ee8727a7 --- /dev/null +++ b/packages/coding-agent/test/patch/imports/go.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { goImportHandler } from "../../../src/patch/imports/go"; +import type { ImportSpec } from "../../../src/patch/types"; + +function apply(content: string, imports: ImportSpec[]) { + return goImportHandler.apply(content, imports); +} + +describe("go import handler", () => { + it("converts a single import into a block when adding another import", () => { + const source = ["package main", "", 'import "fmt"', "", "func main() {}", ""].join("\n"); + + const result = apply(source, [{ from: "os" }]); + + expect(result.content).toBe( + ["package main", "", "import (", '\t"fmt"', '\t"os"', ")", "", "func main() {}", ""].join("\n"), + ); + expect(result.added).toEqual(['"os"']); + expect(result.warnings).toEqual([]); + }); + + it("groups stdlib imports before third-party imports in a new block", () => { + const source = ["package main", "", "func main() {}", ""].join("\n"); + + const result = apply(source, [{ from: "github.com/acme/project" }, { from: "fmt" }]); + + expect(result.content).toBe( + [ + "package main", + "", + "import (", + '\t"fmt"', + "", + '\t"github.com/acme/project"', + ")", + "", + "func main() {}", + "", + ].join("\n"), + ); + expect(result.warnings).toEqual([]); + }); + + it("deduplicates repeated requested imports", () => { + const source = ["package main", "", "func main() {}", ""].join("\n"); + + const result = apply(source, [ + { from: "fmt" }, + { from: "fmt" }, + { from: "github.com/acme/project" }, + { from: "github.com/acme/project" }, + ]); + + expect(result.content).toBe( + [ + "package main", + "", + "import (", + '\t"fmt"', + "", + '\t"github.com/acme/project"', + ")", + "", + "func main() {}", + "", + ].join("\n"), + ); + expect(result.added).toEqual(['"fmt"', '"github.com/acme/project"']); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/patch/imports/integration.test.ts b/packages/coding-agent/test/patch/imports/integration.test.ts new file mode 100644 index 000000000..479ce0d8c --- /dev/null +++ b/packages/coding-agent/test/patch/imports/integration.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { Snowflake } from "@oh-my-pi/pi-utils"; + +function createTestToolSession(cwd: string, settings: Settings = Settings.isolated()): ToolSession { + const sessionFile = path.join(cwd, "session.jsonl"); + const sessionDir = path.join(cwd, "session"); + return { + cwd, + hasUI: false, + getSessionFile: () => sessionFile, + getSessionSpawns: () => "*", + getArtifactsDir: () => sessionDir, + allocateOutputArtifact: async (toolType: string) => { + fs.mkdirSync(sessionDir, { recursive: true }); + return { + id: `${toolType}-${Snowflake.next()}`, + path: path.join(sessionDir, `${toolType}-${Snowflake.next()}.log`), + }; + }, + settings, + }; +} + +function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string { + return ( + result.content + ?.filter(block => block.type === "text") + .map(block => block.text ?? "") + .join("\n") ?? "" + ); +} + +function getSchemaProperties(tool: EditTool): Record { + const schema = tool.parameters as unknown as { properties?: Record }; + return schema.properties ?? {}; +} + +describe("EditTool import management integration", () => { + let tempDir: string; + let originalEditVariant: string | undefined; + + beforeEach(() => { + originalEditVariant = Bun.env.PI_EDIT_VARIANT; + Bun.env.PI_EDIT_VARIANT = "replace"; + tempDir = path.join(os.tmpdir(), `coding-agent-imports-${Snowflake.next()}`); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalEditVariant === undefined) { + delete Bun.env.PI_EDIT_VARIANT; + } else { + Bun.env.PI_EDIT_VARIANT = originalEditVariant; + } + }); + + it("applies a textual edit and then manages imports in replace mode", async () => { + const filePath = path.join(tempDir, "main.go"); + fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); + + const tool = new EditTool(createTestToolSession(tempDir)); + const result = await tool.execute("replace-go-imports", { + path: filePath, + old_text: 'println("old")', + new_text: 'fmt.Println("new")', + imports: [{ from: "fmt" }], + }); + + expect(getTextOutput(result)).toContain("Successfully replaced"); + expect(await Bun.file(filePath).text()).toBe( + ["package main", "", 'import "fmt"', "", "func main() {", '\tfmt.Println("new")', "}", ""].join("\n"), + ); + }); + it("omits imports from prompts and schemas when the setting is disabled", () => { + const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": false }))); + + expect(tool.description).not.toContain("`imports`"); + expect(getSchemaProperties(tool)).not.toHaveProperty("imports"); + }); + + it("ignores import requests when the setting is disabled", async () => { + const filePath = path.join(tempDir, "main.go"); + fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); + + const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": false }))); + await tool.execute("replace-go-imports-disabled", { + path: filePath, + old_text: 'println("old")', + new_text: 'fmt.Println("new")', + imports: [{ from: "fmt" }], + }); + + expect(await Bun.file(filePath).text()).toBe( + ["package main", "", "func main() {", '\tfmt.Println("new")', "}", ""].join("\n"), + ); + }); + + it("includes imports in prompts and schemas when the setting is enabled", () => { + const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": true }))); + + expect(tool.description).toContain("`imports`"); + expect(getSchemaProperties(tool)).toHaveProperty("imports"); + }); +}); diff --git a/packages/coding-agent/test/patch/imports/python.test.ts b/packages/coding-agent/test/patch/imports/python.test.ts new file mode 100644 index 000000000..a8d11a3b2 --- /dev/null +++ b/packages/coding-agent/test/patch/imports/python.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { pythonImportHandler } from "../../../src/patch/imports/python"; + +describe("pythonImportHandler.apply", () => { + test("inserts after shebang, encoding, and module docstring", () => { + const content = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n"""module docs"""\n\nvalue = 1\n'; + + const result = pythonImportHandler.apply(content, [{ from: "os", imports: ["path"] }]); + + expect(result).toEqual({ + content: + '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n"""module docs"""\n\nfrom os import path\n\nvalue = 1\n', + added: ["from os import path"], + warnings: [], + }); + }); + + test("merges names into an existing from-import", () => { + const content = "from pathlib import Path\n\nvalue = 1\n"; + + const result = pythonImportHandler.apply(content, [{ from: "pathlib", imports: ["PurePath"] }]); + + expect(result.content).toBe("from pathlib import Path, PurePath\n\nvalue = 1\n"); + expect(result.added).toEqual(["from pathlib import PurePath"]); + expect(result.warnings).toEqual([]); + }); + + test("preserves existing import grouping when adding to an existing group", () => { + const content = "import os\n\nimport requests\n\nfrom .local import thing\n\nvalue = 1\n"; + + const result = pythonImportHandler.apply(content, [{ from: "sys" }]); + + expect(result.content).toBe( + "import os\nimport sys\n\nimport requests\n\nfrom .local import thing\n\nvalue = 1\n", + ); + expect(result.added).toEqual(["import sys"]); + expect(result.warnings).toEqual([]); + }); + + test("dedupes an already present import", () => { + const content = "from pathlib import Path\n"; + + const result = pythonImportHandler.apply(content, [{ from: "pathlib", imports: ["Path"] }]); + + expect(result).toEqual({ + content, + added: [], + warnings: [], + }); + }); +}); diff --git a/packages/coding-agent/test/patch/imports/rust.test.ts b/packages/coding-agent/test/patch/imports/rust.test.ts new file mode 100644 index 000000000..9ca92c914 --- /dev/null +++ b/packages/coding-agent/test/patch/imports/rust.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { rustImportHandler } from "../../../src/patch/imports/rust"; + +describe("rustImportHandler.apply", () => { + test("adds a use statement to a file with no imports", () => { + const result = rustImportHandler.apply("fn main() {}\n", [{ from: "std::fmt::Debug" }]); + + expect(result).toEqual({ + content: "use std::fmt::Debug;\n\nfn main() {}\n", + added: ["use std::fmt::Debug;"], + warnings: [], + }); + }); + + test("merges a requested member into an existing brace-group import", () => { + const content = "use std::fmt::{Debug};\n\nfn main() {}\n"; + + const result = rustImportHandler.apply(content, [{ from: "std::fmt", imports: ["Display"] }]); + + expect(result.content).toBe("use std::fmt::{Debug, Display};\n\nfn main() {}\n"); + expect(result.added).toEqual(["use std::fmt::{Display};"]); + expect(result.warnings).toEqual([]); + }); + + test("keeps distinct simple-path imports when they cannot be merged semantically", () => { + const content = "use crate::config;\n\nfn main() {}\n"; + + const result = rustImportHandler.apply(content, [{ from: "crate::config::load" }]); + + expect(result.content).toBe("use crate::config;\nuse crate::config::load;\n\nfn main() {}\n"); + expect(result.added).toEqual(["use crate::config::load;"]); + expect(result.warnings).toEqual([]); + }); + + test("dedupes an already present use statement", () => { + const content = "use std::fmt::Debug;\n"; + + const result = rustImportHandler.apply(content, [{ from: "std::fmt::Debug" }]); + + expect(result).toEqual({ + content, + added: [], + warnings: [], + }); + }); +}); diff --git a/packages/coding-agent/test/patch/imports/typescript.test.ts b/packages/coding-agent/test/patch/imports/typescript.test.ts new file mode 100644 index 000000000..d9bd1d32f --- /dev/null +++ b/packages/coding-agent/test/patch/imports/typescript.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { typescriptImportHandler } from "../../../src/patch/imports/typescript"; + +describe("typescriptImportHandler.apply", () => { + test("adds an import block to a file with no imports", () => { + const result = typescriptImportHandler.apply("const answer = 42;\n", [{ from: "react", imports: ["useMemo"] }]); + + expect(result).toEqual({ + content: 'import { useMemo } from "react";\n\nconst answer = 42;\n', + added: ['import { useMemo } from "react";'], + warnings: [], + }); + }); + + test("merges named imports into an existing module import", () => { + const content = 'import { useState } from "react";\n\nconst answer = 42;\n'; + + const result = typescriptImportHandler.apply(content, [{ from: "react", imports: ["useMemo"] }]); + + expect(result.content).toBe('import { useMemo, useState } from "react";\n\nconst answer = 42;\n'); + expect(result.added).toEqual(['import { useMemo } from "react";']); + expect(result.warnings).toEqual([]); + }); + + test("dedupes an already present import", () => { + const content = 'import { useMemo } from "react";\n'; + + const result = typescriptImportHandler.apply(content, [{ from: "react", imports: ["useMemo"] }]); + + expect(result).toEqual({ + content, + added: [], + warnings: [], + }); + }); + + test("derives an import assertion for known asset types", () => { + const result = typescriptImportHandler.apply("const answer = 42;\n", [ + { from: "./guide.adoc", default: "guide" }, + ]); + + expect(result).toEqual({ + content: 'import guide from "./guide.adoc" with { type: "text" };\n\nconst answer = 42;\n', + added: ['import guide from "./guide.adoc" with { type: "text" };'], + warnings: [], + }); + }); +}); diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index 466e09db7..6312f67e0 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -124,29 +124,6 @@ async function createPrFixture(): Promise<{ }; } -async function createGitRepoFixture(): Promise<{ - baseDir: string; - repoRoot: string; - headSha: string; -}> { - const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-run-watch-repo-")); - const repoRoot = path.join(baseDir, "repo"); - - await fs.mkdir(repoRoot, { recursive: true }); - runGit(baseDir, ["init", "-b", "main", repoRoot]); - runGit(repoRoot, ["config", "user.name", "Test User"]); - runGit(repoRoot, ["config", "user.email", "test@example.com"]); - await fs.writeFile(path.join(repoRoot, "README.md"), "watch me\n"); - runGit(repoRoot, ["add", "README.md"]); - runGit(repoRoot, ["commit", "-m", "initial commit"]); - - return { - baseDir, - repoRoot, - headSha: runGit(repoRoot, ["rev-parse", "HEAD"]), - }; -} - describe("GitHub CLI tools", () => { afterEach(() => { vi.useRealTimers(); @@ -514,253 +491,4 @@ describe("GitHub CLI tools", () => { await fs.rm(artifactsDir, { recursive: true, force: true }); } }); - - it("fast-fails commit watch after the grace window once any job fails", async () => { - vi.useFakeTimers(); - - const fixture = await createGitRepoFixture(); - const repo = "owner/repo"; - const runId = 88; - const updates: string[] = []; - const runListResponses = [ - { - total_count: 1, - workflow_runs: [ - { - id: runId, - name: "CI", - display_title: "main checks", - status: "in_progress", - conclusion: null, - head_branch: "main", - head_sha: fixture.headSha, - created_at: "2026-04-01T08:00:00Z", - updated_at: "2026-04-01T08:01:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}`, - }, - ], - }, - { - total_count: 1, - workflow_runs: [ - { - id: runId, - name: "CI", - display_title: "main checks", - status: "in_progress", - conclusion: null, - head_branch: "main", - head_sha: fixture.headSha, - created_at: "2026-04-01T08:00:00Z", - updated_at: "2026-04-01T08:02:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}`, - }, - ], - }, - { - total_count: 1, - workflow_runs: [ - { - id: runId, - name: "CI", - display_title: "main checks", - status: "in_progress", - conclusion: null, - head_branch: "main", - head_sha: fixture.headSha, - created_at: "2026-04-01T08:00:00Z", - updated_at: "2026-04-01T08:03:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}`, - }, - ], - }, - ]; - const jobResponses = [ - { - total_count: 3, - jobs: [ - { - id: 301, - name: "build", - status: "completed", - conclusion: "success", - started_at: "2026-04-01T08:00:00Z", - completed_at: "2026-04-01T08:01:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/301`, - }, - { - id: 302, - name: "test", - status: "in_progress", - conclusion: null, - started_at: "2026-04-01T08:00:00Z", - completed_at: null, - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/302`, - }, - { - id: 303, - name: "lint", - status: "queued", - conclusion: null, - started_at: null, - completed_at: null, - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/303`, - }, - ], - }, - { - total_count: 3, - jobs: [ - { - id: 301, - name: "build", - status: "completed", - conclusion: "success", - started_at: "2026-04-01T08:00:00Z", - completed_at: "2026-04-01T08:01:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/301`, - }, - { - id: 302, - name: "test", - status: "completed", - conclusion: "failure", - started_at: "2026-04-01T08:00:00Z", - completed_at: "2026-04-01T08:02:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/302`, - }, - { - id: 303, - name: "lint", - status: "in_progress", - conclusion: null, - started_at: "2026-04-01T08:01:30Z", - completed_at: null, - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/303`, - }, - ], - }, - { - total_count: 3, - jobs: [ - { - id: 301, - name: "build", - status: "completed", - conclusion: "success", - started_at: "2026-04-01T08:00:00Z", - completed_at: "2026-04-01T08:01:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/301`, - }, - { - id: 302, - name: "test", - status: "completed", - conclusion: "failure", - started_at: "2026-04-01T08:00:00Z", - completed_at: "2026-04-01T08:02:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/302`, - }, - { - id: 303, - name: "lint", - status: "completed", - conclusion: "failure", - started_at: "2026-04-01T08:01:30Z", - completed_at: "2026-04-01T08:03:00Z", - html_url: `https://github.com/${repo}/actions/runs/${runId}/job/303`, - }, - ], - }, - ]; - - vi.spyOn(git.github, "text").mockImplementation(async (_cwd, args) => { - if (args.join(" ") === "repo view --json nameWithOwner -q .nameWithOwner") { - return repo; - } - throw new Error(`Unexpected runGhText args: ${args.join(" ")}`); - }); - const runGhJsonSpy = vi.spyOn(git.github, "json").mockImplementation(async (_cwd, args) => { - const endpoint = args[3]; - if (args[0] === "api" && endpoint === `/repos/${repo}/actions/runs`) { - const response = runListResponses.shift(); - if (!response) { - throw new Error("No more run list responses available"); - } - return response as never; - } - - if (args[0] === "api" && endpoint === `/repos/${repo}/actions/runs/${runId}/jobs`) { - const response = jobResponses.shift(); - if (!response) { - throw new Error("No more job responses available"); - } - return response as never; - } - - throw new Error(`Unexpected runGhJson args: ${args.join(" ")}`); - }); - vi.spyOn(git.github, "run").mockImplementation(async (_cwd, args) => { - if (args[1] === `/repos/${repo}/actions/jobs/302/logs`) { - return { - exitCode: 0, - stdout: "setup\ncompile\nassertion failed\ntrace line", - stderr: "", - }; - } - - if (args[1] === `/repos/${repo}/actions/jobs/303/logs`) { - return { - exitCode: 0, - stdout: "prepare\nlint\nstyle failure\nsecond failure", - stderr: "", - }; - } - - throw new Error(`Unexpected runGhCommand args: ${args.join(" ")}`); - }); - - try { - const tool = new GhRunWatchTool(createSession(fixture.repoRoot)); - const resultPromise = tool.execute("run-watch", { tail: 2 }, undefined, update => { - const text = update.content[0]; - if (text?.type === "text") { - updates.push(text.text); - } - }); - - await vi.advanceTimersByTimeAsync(8000); - const settled = await Promise.race([ - resultPromise.then(result => ({ done: true as const, result })), - Promise.resolve({ done: false as const }), - ]); - - expect(settled.done).toBe(true); - if (!settled.done) { - return; - } - - const text = settled.result.content[0]?.type === "text" ? settled.result.content[0].text : ""; - expect(text).toContain(`# GitHub Actions for ${fixture.headSha.slice(0, 12)}`); - expect(text).toContain(`Commit: ${fixture.headSha}`); - expect(text).toContain("Status: in_progress"); - expect(text).toContain("### test [failure]"); - expect(text).toContain("assertion failed"); - expect(text).toContain("trace line"); - expect(text).toContain("### lint [failure]"); - expect(text).toContain("style failure"); - expect(text).toContain("second failure"); - expect(text).toContain("Workflow runs for this commit failed."); - expect( - updates.some(text => - text.includes("Failure detected. Waiting 5s to capture concurrent failures before fetching logs."), - ), - ).toBe(true); - expect(runGhJsonSpy.mock.calls.filter(([, args]) => args[3] === `/repos/${repo}/actions/runs`)).toHaveLength( - 3, - ); - } finally { - await fs.rm(fixture.baseDir, { recursive: true, force: true }); - } - }); });