scai
Version:
> **AI-powered CLI for local code analysis, commit message suggestions, and natural-language queries.** 100% local, private, GDPR-friendly, made in Denmark/EU with ❤️.
196 lines (195 loc) • 8.38 kB
JavaScript
import chalk from "chalk";
import { getCommentSyntax } from "../../utils/commentMap.js";
import { detectFileType } from "../../fileRules/detectFileType.js";
export const preserveCodeModule = {
name: "preserveCodeModule",
description: "Ensure code matches original exactly, preserving comments with clear before/after output",
async run(input) {
const { originalContent, content, filepath } = input;
if (!originalContent)
throw new Error("Requires `originalContent`.");
// Determine language from filepath extension
const ext = "." + (filepath?.split(".").pop() || "ts");
const language = detectFileType(filepath ?? ext);
const syntax = getCommentSyntax(language);
// --- Normalize line endings ---
const normalize = (txt) => txt.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const origLines = normalize(originalContent).split("\n");
const newLines = normalize(content).split("\n");
// --- Classify line for comment preservation ---
let inBlockComment = false;
let blockLines = [];
const classifyLine = (line) => {
const trimmed = line.trimStart();
// Single-line comments
for (const s of syntax.singleLine) {
if (trimmed.startsWith(s))
return line;
}
const multiLineComments = syntax.multiLine ?? [];
if (!inBlockComment) {
for (const { start, end } of multiLineComments) {
if (trimmed.startsWith(start) || trimmed.startsWith("/**")) {
blockLines = [line];
if (trimmed.includes(end) && trimmed.indexOf(end) > trimmed.indexOf(start)) {
return blockLines.join("\n");
}
else {
inBlockComment = true;
return null;
}
}
}
// ✅ New: Handle stray lines that look like part of a comment
if (trimmed.startsWith("*")) {
return line;
}
}
else {
blockLines.push(line);
if (trimmed.includes("*/")) {
inBlockComment = false;
const fullBlock = blockLines.join("\n");
blockLines = [];
return fullBlock;
}
return null; // still inside block
}
return "code";
};
// --- Collect comments into map ---
function collectCommentsMap(lines) {
const map = new Map();
let commentBuffer = [];
for (const line of lines) {
const result = classifyLine(line);
if (result === "code") {
if (commentBuffer.length > 0) {
const key = line.trim().toLowerCase();
const block = commentBuffer.join("\n").trim().toLowerCase();
if (!map.has(key))
map.set(key, new Set());
map.get(key).add(block);
commentBuffer = [];
}
continue;
}
if (typeof result === "string")
commentBuffer.push(result);
}
if (commentBuffer.length > 0) {
const key = "";
const block = commentBuffer.join("\n").trim().toLowerCase();
if (!map.has(key))
map.set(key, new Set());
map.get(key).add(block);
}
if (blockLines.length > 0) {
const key = "";
const block = blockLines.join("\n").trim().toLowerCase();
if (!map.has(key))
map.set(key, new Set());
map.get(key).add(block);
blockLines = [];
}
return map;
}
// --- Step 1: Collect comments ---
const modelComments = collectCommentsMap(newLines);
const origComments = collectCommentsMap(origLines);
// --- Step 2: Remove duplicates ---
for (const [key, modelSet] of modelComments.entries()) {
const origSet = origComments.get(key);
if (!origSet)
continue;
for (const c of Array.from(modelSet)) {
if (origSet.has(c))
modelSet.delete(c);
}
if (modelSet.size === 0)
modelComments.delete(key);
}
// --- Step 3: Build fixed lines ---
let fixedLines = [];
for (const origLine of origLines) {
const key = origLine.trim().toLowerCase();
if (modelComments.has(key)) {
for (const block of modelComments.get(key)) {
for (const line of block.split("\n")) {
const norm = line.trim().toLowerCase();
if (!fixedLines.some(l => l.trim().toLowerCase() === norm)) {
fixedLines.push(line);
}
}
}
}
fixedLines.push(origLine);
}
// --- Step 4: Remove opening/closing fences at top/bottom ---
while (fixedLines.length && /^```(?:\w+)?$/.test(fixedLines[0].trim())) {
fixedLines.shift();
}
while (fixedLines.length && /^```(?:\w+)?$/.test(fixedLines[fixedLines.length - 1].trim())) {
fixedLines.pop();
}
// --- Step 5: Remove opening/closing fences at top/bottom ---
while (fixedLines.length && /^```(?:\w+)?$/.test(fixedLines[0].trim())) {
console.log(chalk.red(`[preserveCodeModule] Removing top fence: "${fixedLines[0].trim()}"`));
fixedLines.shift();
}
while (fixedLines.length && /^```(?:\w+)?$/.test(fixedLines[fixedLines.length - 1].trim())) {
console.log(chalk.red(`[preserveCodeModule] Removing bottom fence: "${fixedLines[fixedLines.length - 1].trim()}"`));
fixedLines.pop();
}
// --- Step 6: Remove any lingering triple ticks inside content ---
fixedLines = fixedLines.filter(line => {
if (/^```(?:\w+)?$/.test(line.trim())) {
console.log(chalk.red(`[preserveCodeModule] Removing lingering fence: "${line.trim()}"`));
return false;
}
return true;
});
// --- Step 7 (final): Clean stray '*' and '*/' lines ---
let insideBlock = false;
fixedLines = fixedLines.map(line => {
const trimmed = line.trim();
// Enter block comment
if (trimmed.startsWith("/*") || trimmed.startsWith("/**")) {
insideBlock = true;
return line;
}
// Exit block comment
if (trimmed.startsWith("*/")) {
if (insideBlock) {
insideBlock = false;
return line; // keep valid closer
}
else {
return ""; // remove stray closer
}
}
// If inside a block, keep '*' lines as-is
if (insideBlock && trimmed.startsWith("*")) {
return line;
}
// If not inside a block but line starts with '*'
if (!insideBlock && trimmed.startsWith("*")) {
const afterStar = trimmed.slice(1).trim();
// ✅ If it's a JSDoc tag (starts with @), keep as-is
if (afterStar.startsWith("@")) {
return line;
}
// Otherwise, treat as stray and convert to //
const indent = line.slice(0, line.indexOf("*"));
return indent + "//" + line.slice(line.indexOf("*") + 1);
}
return line;
}).filter(line => line !== ""); // remove empty stray lines
// --- Return PromptOutput ---
return {
content: fixedLines.join("\n"),
filepath,
mode: "overwrite",
};
}
};