revert: language-specific import management experiment

This reverts commit f044f3ba75.
This commit is contained in:
can1357
2026-04-04 21:35:39 +02:00
parent db995107af
commit 511fddd8de
20 changed files with 47 additions and 4176 deletions
-13
View File
@@ -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
@@ -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,
@@ -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<T>(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<ImportSpec, "from" | "system">): string {
return `${spec.system ? "system" : "local"}:${spec.from}`;
}
function dedupeSpecs(specs: ImportSpec[], existing: Iterable<Pick<ImportSpec, "from" | "system">>): ImportSpec[] {
const seen = new Set<string>();
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<number, string[]>, 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<number, string[]>,
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<number, string[]>): 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<number, string[]>();
const groupedSpecs: Record<IncludeGroup, ImportSpec[]> = {
system: sortSpecs(specs.filter(spec => spec.system)),
local: sortSpecs(specs.filter(spec => !spec.system)),
};
const groupedEntries: Record<IncludeGroup, ParsedIncludeEntry[]> = {
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<number, string[]>();
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<number, string[]>();
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 };
@@ -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+(?:(?<alias>[._]|[A-Za-z_]\w*)\s+)?"(?<source>[^"]+)"(?:\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*(?:(?<alias>[._]|[A-Za-z_]\w*)\s+)?"(?<source>[^"]+)"(?:\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<GoImportGroup>();
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<string, GoImportEntry>();
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<string, GoImportEntry>();
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<GoImportGroup, GoImportEntry[]>();
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 };
@@ -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<Record<string, ImportHandler>> = {
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";
@@ -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<string>([
"__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<PythonImportStyle>).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<ImportGroup, ParsedPythonImport[]>();
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<string, ParsedPythonImport>();
const fromRecords = new Map<string, ParsedPythonImport>();
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<ImportGroup, ParsedPythonImport[]>();
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 };
@@ -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<RustImportGroup>();
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 = /^(?<path>.+?)(?:\s+as\s+(?<alias>[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 = /^(?<prefix>.+?)::\{(?<members>[^{}]+)\}$/.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>): 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<string, RustImportEntry>();
const aliaslessSimplePaths = new Set<string>();
const groupEntries = new Map<string, RustImportEntry>();
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<string, RustImportEntry>();
const addedGroupMembers = new Map<string, Set<string>>();
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<string>();
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 };
@@ -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,
};
File diff suppressed because it is too large Load Diff
+25 -168
View File
@@ -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<typeof replaceEditSchemaWithImports>;
export type PatchParams = Static<typeof patchEditSchemaWithImports>;
export type ReplaceParams = Static<typeof replaceEditSchema>;
export type PatchParams = Static<typeof patchEditSchema>;
/**
* 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<typeof hashlineEditSchema>;
export type HashlineParams = Static<typeof hashlineEditParamsSchemaWithImports>;
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// 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<TInput> {
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<TInput> {
}
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<TInput> {
* 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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
);
}
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<TInput> {
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<TInput> {
: `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 },
};
}
}
-20
View File
@@ -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
// ═══════════════════════════════════════════════════════════════════════════
@@ -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}}
</operations>
</operations>
<examples>
All examples below reference the same file:
@@ -56,28 +42,6 @@ All examples below reference the same file:
{{hline 18 "}"}}
```
{{#if editManageImportsEnabled}}
<example name="replace a block body">
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.
</example>
{{else}}
<example name="replace a block body">
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
}
```
</example>
{{/if}}
<example name="replace whole block including closing brace">
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.
</critical>
</critical>
@@ -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}}
</instruction>
<parameters>
@@ -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 }
```
</parameters>
@@ -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.
</critical>
{{#if editManageImportsEnabled}}
<example name="update">
```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.
<example name="create">
edit {"path":"hello.txt","op":"create","diff":"Hello\n"}
</example>
<example name="typescript-mixed-import">
```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`.
<example name="update">
edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}
</example>
<example name="rename">
edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"}
</example>
<example name="delete">
edit {"path":"obsolete.txt","op":"delete"}
</example>
{{/if}}
<avoid>
{{#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)
</avoid>
@@ -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}}
</instruction>
<parameters>
```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}}
};
```
</parameters>
<output>
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.
</output>
@@ -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.
</critical>
{{#if editManageImportsEnabled}}
<example name="typescript-helper">
```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.
</example>
<example name="namespace-import">
```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`.
</example>
{{/if}}
<bash-alternatives>
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:
@@ -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 <vector>", "", '#include "widget/detail.h"', "", "class Widget {};", ""].join(
"\n",
),
);
expect(result.added).toEqual(["#include <vector>", '#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 <string>", "", '#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 <string>",
"#include <vector>",
"",
'#include "app/bar.h"',
'#include "app/foo.h"',
"",
"int main();",
"",
].join("\n"),
);
expect(result.added).toEqual(["#include <vector>", '#include "app/bar.h"']);
expect(result.warnings).toEqual([]);
});
});
@@ -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([]);
});
});
@@ -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<string, unknown> {
const schema = tool.parameters as unknown as { properties?: Record<string, unknown> };
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");
});
});
@@ -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: [],
});
});
});
@@ -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: [],
});
});
});
@@ -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: [],
});
});
});