revert: language-specific import management experiment
This reverts commit f044f3ba75.
This commit is contained in:
@@ -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
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user