From 511fddd8de65597d497af0ea6a5d1b1afa3865a4 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Apr 2026 21:35:39 +0200 Subject: [PATCH] revert: language-specific import management experiment This reverts commit f044f3ba756e0a7e15f45b3f2403c71961703d17. --- packages/coding-agent/CHANGELOG.md | 13 - .../src/config/settings-schema.ts | 10 - .../coding-agent/src/patch/imports/cpp.ts | 470 ------- packages/coding-agent/src/patch/imports/go.ts | 435 ------- .../coding-agent/src/patch/imports/index.ts | 60 - .../coding-agent/src/patch/imports/python.ts | 653 ---------- .../coding-agent/src/patch/imports/rust.ts | 594 --------- .../coding-agent/src/patch/imports/types.ts | 47 - .../src/patch/imports/typescript.ts | 1150 ----------------- packages/coding-agent/src/patch/index.ts | 193 +-- packages/coding-agent/src/patch/types.ts | 20 - .../src/prompts/tools/hashline.md | 41 +- .../coding-agent/src/prompts/tools/patch.md | 79 +- .../coding-agent/src/prompts/tools/replace.md | 64 +- .../test/patch/imports/cpp.test.ts | 67 - .../test/patch/imports/go.test.ts | 71 - .../test/patch/imports/integration.test.ts | 111 -- .../test/patch/imports/python.test.ts | 51 - .../test/patch/imports/rust.test.ts | 46 - .../test/patch/imports/typescript.test.ts | 48 - 20 files changed, 47 insertions(+), 4176 deletions(-) delete mode 100644 packages/coding-agent/src/patch/imports/cpp.ts delete mode 100644 packages/coding-agent/src/patch/imports/go.ts delete mode 100644 packages/coding-agent/src/patch/imports/index.ts delete mode 100644 packages/coding-agent/src/patch/imports/python.ts delete mode 100644 packages/coding-agent/src/patch/imports/rust.ts delete mode 100644 packages/coding-agent/src/patch/imports/types.ts delete mode 100644 packages/coding-agent/src/patch/imports/typescript.ts delete mode 100644 packages/coding-agent/test/patch/imports/cpp.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/go.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/integration.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/python.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/rust.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/typescript.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8c3b28a9..69031f997 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,16 +4,6 @@ ### 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) @@ -24,9 +14,6 @@ ### 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 32a8a1406..019063bba 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -958,16 +958,6 @@ 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 deleted file mode 100644 index ff730d216..000000000 --- a/packages/coding-agent/src/patch/imports/cpp.ts +++ /dev/null @@ -1,470 +0,0 @@ -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 deleted file mode 100644 index 4d5b3367f..000000000 --- a/packages/coding-agent/src/patch/imports/go.ts +++ /dev/null @@ -1,435 +0,0 @@ -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 deleted file mode 100644 index 6b9e14986..000000000 --- a/packages/coding-agent/src/patch/imports/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index c2480669d..000000000 --- a/packages/coding-agent/src/patch/imports/python.ts +++ /dev/null @@ -1,653 +0,0 @@ -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 deleted file mode 100644 index 7f299ffd8..000000000 --- a/packages/coding-agent/src/patch/imports/rust.ts +++ /dev/null @@ -1,594 +0,0 @@ -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 deleted file mode 100644 index 6bf67bc3a..000000000 --- a/packages/coding-agent/src/patch/imports/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index fccbb9ac3..000000000 --- a/packages/coding-agent/src/patch/imports/typescript.ts +++ /dev/null @@ -1,1150 +0,0 @@ -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 a80089173..0b47ee05f 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -44,11 +44,10 @@ 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"; -import type { FileSystem, ImportSpec, Operation, PatchInput } from "./types"; +// Internal imports +import type { FileSystem, Operation, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -64,7 +63,6 @@ export * from "./diff"; export * from "./fuzzy"; // Hashline export * from "./hashline"; -export * from "./imports"; // Normalization export * from "./normalize"; // Parsing @@ -78,44 +76,14 @@ export * from "./types"; // Schemas // ═══════════════════════════════════════════════════════════════════════════ -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({ +const replaceEditSchema = 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 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({ +const patchEditSchema = Type.Object({ path: Type.String({ description: "File path" }), op: Type.Optional( StringEnum(["create", "delete", "update"], { @@ -126,22 +94,8 @@ const patchEditSchemaBase = Type.Object({ diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), }); -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; +export type ReplaceParams = Static; +export type PatchParams = Static; /** * Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`. @@ -250,7 +204,7 @@ const hashlineEditSchema = Type.Object( { additionalProperties: false }, ); -const hashlineEditParamsSchemaBase = Type.Object( +const hashlineEditParamsSchema = Type.Object( { path: Type.String({ description: "path" }), edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), @@ -260,22 +214,8 @@ const hashlineEditParamsSchemaBase = 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 @@ -417,31 +357,11 @@ 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 replaceEditSchemaBase - | typeof replaceEditSchemaWithImports - | typeof patchEditSchemaBase - | typeof patchEditSchemaWithImports - | typeof hashlineEditParamsSchemaBase - | typeof hashlineEditParamsSchemaWithImports; +type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditParamsSchema; export type EditMode = "replace" | "patch" | "hashline"; @@ -459,15 +379,6 @@ 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. * @@ -482,12 +393,8 @@ 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 { @@ -530,10 +437,6 @@ 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"); @@ -566,14 +469,13 @@ 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, promptContext); + return renderPromptTemplate(patchDescription); case "hashline": - return renderPromptTemplate(hashlineDescription, promptContext); + return renderPromptTemplate(hashlineDescription); default: - return renderPromptTemplate(replaceDescription, promptContext); + return renderPromptTemplate(replaceDescription); } } @@ -583,11 +485,11 @@ export class EditTool implements AgentTool { get parameters(): TInput { switch (this.mode) { case "patch": - return this.#patchSchema; + return patchEditSchema; case "hashline": - return this.#hashlineSchema; + return hashlineEditParamsSchema; default: - return this.#replaceSchema; + return replaceEditSchema; } } @@ -608,7 +510,6 @@ 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 }); @@ -673,13 +574,9 @@ export class EditTool implements AgentTool { throw new Error(`File not found: ${path}`); } } - 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")}` - : ""; + await fs.writeFile(absolutePath, lines.join("\n")); return { - content: [{ type: "text", text: `Created ${path}${warningsBlock}` }], + content: [{ type: "text", text: `Created ${path}` }], details: { diff: "", op: "create", @@ -701,11 +598,10 @@ 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: importResult.content, + text: normalizedText, firstChangedLine: anchorResult.firstChangedLine, - warnings: [...(anchorResult.warnings ?? []), ...importResult.warnings], + warnings: anchorResult.warnings, noopEdits: anchorResult.noopEdits, }; if (originalNormalized === result.text && !move) { @@ -815,7 +711,6 @@ 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"; @@ -841,36 +736,6 @@ 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") { @@ -904,12 +769,12 @@ export class EditTool implements AgentTool { break; } - let diagnostics = importDiagnostics ?? fs.getDiagnostics(); + let diagnostics = fs.getDiagnostics(); if (op === "delete" && batchRequest?.flush) { const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); diagnostics ??= flushedDiagnostics; } - const patchWarnings = [...(result.warnings ?? []), ...importWarnings]; + const patchWarnings = result.warnings ?? []; const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); const meta = outputMeta() @@ -936,7 +801,6 @@ 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); @@ -996,8 +860,7 @@ export class EditTool implements AgentTool { ); } - const importResult = applyRequestedImports(path, result.content, imports, this.#manageImports); - const finalContent = bom + restoreLineEndings(importResult.content, originalEnding); + const finalContent = bom + restoreLineEndings(result.content, originalEnding); const diagnostics = await this.#writethrough( absolutePath, finalContent, @@ -1006,8 +869,7 @@ export class EditTool implements AgentTool { batchRequest, ); invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, importResult.content); - const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, importResult.warnings); + const diffResult = generateDiffString(normalizedContent, result.content); const resultText = result.count > 1 @@ -1015,17 +877,12 @@ export class EditTool implements AgentTool { : `Successfully replaced text in ${path}.`; const meta = outputMeta() - .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: resultText }], - details: { - diff: diffResult.diff, - firstChangedLine: diffResult.firstChangedLine, - diagnostics: mergedDiagnostics, - meta, - }, + details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta }, }; } } diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index e848e8a2a..d612c3ff7 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -174,26 +174,6 @@ 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 cba7cb2f5..1845dff40 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -8,16 +8,6 @@ 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) @@ -27,11 +17,7 @@ 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: @@ -56,28 +42,6 @@ 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) {`. ``` @@ -93,7 +57,6 @@ 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 `}`. @@ -165,4 +128,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 191c0bef4..6ad8b460e 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -14,12 +14,6 @@ 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}} @@ -29,22 +23,13 @@ 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{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } + | { path: string, op: "update", diff: string } // Diff is full file content, no prefixes. - | { path: string, op: "create", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } + | { path: string, op: "create", diff: string } // No diff for delete. | { path: string, op: "delete" } -{{#if editManageImportsEnabled}} - -type ImportSpec = { - from: string; - imports?: string[]; - default?: string; - namespace?: string; - alias?: string; - system?: boolean; -} -{{/if}} + // New path for update+move. + | { path: string, op: "update", rename: string, diff: string } ``` @@ -64,50 +49,24 @@ 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. -{{#if editManageImportsEnabled}} - -```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":"hello.txt","op":"create","diff":"Hello\n"} - -```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`. + +edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"} + + + +edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"} + + + +edit {"path":"obsolete.txt","op":"delete"} -{{/if}} -{{#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}} +- 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) diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index ff7d0e2bf..eb6b13879 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -5,34 +5,8 @@ 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. @@ -41,44 +15,8 @@ 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 deleted file mode 100644 index 34717c6e2..000000000 --- a/packages/coding-agent/test/patch/imports/cpp.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 8ee8727a7..000000000 --- a/packages/coding-agent/test/patch/imports/go.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 26fc2d116..000000000 --- a/packages/coding-agent/test/patch/imports/integration.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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, Settings.isolated({ "edit.manageImports": true }))); - 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 deleted file mode 100644 index a8d11a3b2..000000000 --- a/packages/coding-agent/test/patch/imports/python.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 9ca92c914..000000000 --- a/packages/coding-agent/test/patch/imports/rust.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index d9bd1d32f..000000000 --- a/packages/coding-agent/test/patch/imports/typescript.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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: [], - }); - }); -});