revert: language-specific import management experiment
This reverts commit f044f3ba75.
This commit is contained in:
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user