pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
292 lines (291 loc) • 9.09 kB
JavaScript
/**
* Tool Output Sanitization for pi-lens
*
* Cleans and normalizes tool output for display to users.
* Removes ANSI codes, extracts key error messages, etc.
*/
// --- Constants ---
// ANSI escape codes for colors and formatting
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
const ANSI_ESCAPE_EXTENDED = /\x1b\[[0-9;]*[A-Za-z]/g;
// Common error patterns from different tools
const ERROR_INDICATORS = [
/\berror\b/i,
/\bfailed\b/i,
/\bfatal\b/i,
/\binvalid\b/i,
/\bunexpected\b/i,
/\bexpected\b/i,
/\bsyntax\b/i,
/\bcannot find\b/i,
/\bnot found\b/i,
/\bno such\b/i,
];
// Patterns that indicate a line is a "detail" rather than an error
const DETAIL_PATTERNS = [
/^help wanted/i,
/^note:/i,
/^hint:/i,
/^→/,
/^\s*at\s+/, // Stack traces
/^ {4}/, // Indented continuation
];
// --- Core Sanitization Functions ---
/**
* Remove ANSI escape sequences from a string.
*/
export function stripAnsi(text) {
return text.replace(ANSI_ESCAPE, "").replace(ANSI_ESCAPE_EXTENDED, "");
}
/**
* Normalize whitespace in a string.
* Collapses multiple spaces/tabs to single space, trims lines.
*/
export function normalizeWhitespace(text) {
return text
.split(/\r?\n/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter((line) => line.length > 0)
.join("\n");
}
/**
* Check if a line contains error indicators.
*/
function isErrorLine(line) {
const cleanLine = stripAnsi(line).trim();
return ERROR_INDICATORS.some((pattern) => pattern.test(cleanLine));
}
/**
* Check if a line is a "detail" line (continuation, stack trace, etc.)
* that shouldn't be shown as a standalone error.
*/
function isDetailLine(line) {
const cleanLine = stripAnsi(line).trim();
return DETAIL_PATTERNS.some((pattern) => pattern.test(cleanLine));
}
/**
* Sanitize a single line of tool output.
* Removes ANSI codes and normalizes common patterns.
*/
export function sanitizeLine(line) {
return stripAnsi(line)
.replace(/^\s*\[error\]\s*/i, "")
.replace(/^\s*error:\s*/i, "")
.replace(/^\s*[×✖✘✗]\s*/u, "")
.replace(/^\s*[✓✔]\s*/u, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Sanitize multi-line tool output.
* Returns cleaned lines, filtered to relevant content.
*/
export function sanitizeOutput(output) {
if (!output || typeof output !== "string") {
return "";
}
const lines = output.split(/\r?\n/);
const sanitized = lines
.map((line) => sanitizeLine(line))
.filter((line) => line.length > 0)
.filter((line) => !isDetailLine(line));
return sanitized.join("\n");
}
/**
* Extract the most relevant error message from tool output.
* Returns the first line that contains error indicators, or the first non-empty line.
*/
export function extractErrorMessage(output) {
if (!output || typeof output !== "string") {
return undefined;
}
const lines = output
.split(/\r?\n/)
.map((line) => sanitizeLine(line))
.filter((line) => line.length > 0);
if (lines.length === 0) {
return undefined;
}
// Find first line with error indicators
const errorLine = lines.find((line) => isErrorLine(line));
if (errorLine) {
return errorLine;
}
// Fall back to first non-detail line
const firstMain = lines.find((line) => !isDetailLine(line));
return firstMain;
}
/**
* Truncate a message to a maximum length, adding ellipsis if needed.
*/
export function truncateMessage(message, maxLength = 140) {
if (message.length <= maxLength) {
return message;
}
return `${message.slice(0, maxLength - 1)}…`;
}
// --- Tool-Specific Sanitizers ---
/**
* Sanitize TypeScript/LSP diagnostic output.
*/
export function sanitizeTsDiagnostic(output) {
if (!output)
return "";
// TypeScript errors often look like:
// error TS2322: Type 'string' is not assignable to type 'number'.
// or:
// src/file.ts(10,5): error TS2322: ...
const lines = output
.split(/\r?\n/)
.map((line) => stripAnsi(line))
.filter((line) => line.length > 0)
// Filter out non-error lines
.filter((line) => line.includes("error TS") || line.includes("warning TS"));
// If we have file:line:col format errors, extract those
const fileErrors = lines
.filter((line) => /^.+?\([\d,]+\):/.test(line))
.map((line) => {
// Extract file:line:col from the beginning
const match = line.match(/^(.+?\([\d,]+\):)\s*(.+)/);
if (match) {
return `${match[1]} ${match[2].trim()}`;
}
return line.trim();
});
if (fileErrors.length > 0) {
return fileErrors.slice(0, 10).join("\n");
}
// Otherwise just return error lines
return lines.slice(0, 5).join("\n");
}
// --- Helper Functions ---
/**
* Extract error/warning lines from tool output.
* Filters and formats lines containing diagnostic information.
*/
function extractDiagnosticLines(output, predicate, maxLines = 10) {
if (!output)
return "";
return output
.split(/\r?\n/)
.map((line) => stripAnsi(line))
.filter((line) => line.length > 0)
.filter(predicate)
.slice(0, maxLines)
.join("\n");
}
/**
* Format JSON diagnostics array into readable output.
*/
function formatJsonDiagnostics(diags, formatter) {
return diags.slice(0, 10).map(formatter).join("\n");
}
// --- Tool-Specific Sanitizers ---
/**
* Sanitize Rust cargo output.
*/
export function sanitizeRustOutput(output) {
if (!output)
return "";
return extractDiagnosticLines(output, (line) => {
const clean = stripAnsi(line);
return (isErrorLine(clean) || /^\s*-->\s+/.test(clean) // rustc source locations
);
});
}
/**
* Sanitize Go vet output.
*/
export function sanitizeGoOutput(output) {
if (!output)
return "";
return extractDiagnosticLines(output, (line) => {
const clean = stripAnsi(line);
return /(?:^\.\/)|(?:\.go:)/.test(clean) || isErrorLine(clean);
});
}
/**
* Sanitize Biome output.
*/
export function sanitizeBiomeOutput(output) {
if (!output)
return "";
try {
const data = JSON.parse(output);
if (data.diagnostics && Array.isArray(data.diagnostics)) {
return formatJsonDiagnostics(data.diagnostics, (d) => {
const loc = d.location;
const file = loc?.path ? `${loc.path}` : "";
const line = loc?.span?.start?.line ?? 0;
const msg = d.message || "";
return file ? `${file}:${line + 1} ${msg}` : msg;
});
}
}
catch (err) {
void err;
// Not JSON, fall through to text processing
}
// Text output processing
return extractDiagnosticLines(output, (line) => {
const clean = stripAnsi(line);
return isErrorLine(clean) || clean.includes("hint");
});
}
/**
* Sanitize Ruff output.
*/
export function sanitizeRuffOutput(output) {
if (!output)
return "";
try {
const data = JSON.parse(output);
if (Array.isArray(data)) {
return formatJsonDiagnostics(data, (d) => {
const row = d.location?.row ?? 0;
const col = d.location?.column ?? 0;
const code = d.code || "";
const msg = d.message || "";
return `${row}:${col} [${code}] ${msg}`;
});
}
}
catch (err) {
void err;
// Not JSON, fall through
}
// Text output
return extractDiagnosticLines(output, (line) => {
const clean = stripAnsi(line);
return isErrorLine(clean) || /\[(E|W)\d+\]/.test(clean);
});
}
/**
* Sanitize tool output and return both a summary and full details.
* Summary is the first error line, details is the full cleaned output.
*/
export function sanitizeToolOutput(output, maxSummaryLength = 140) {
if (!output || typeof output !== "string") {
return { summary: undefined, details: undefined, truncated: false };
}
const sanitized = sanitizeOutput(output);
const lines = sanitized.split("\n").filter((l) => l.length > 0);
if (lines.length === 0) {
return { summary: undefined, details: undefined, truncated: false };
}
// Summary: first line with error indicators, or first line
const summary = extractErrorMessage(output);
const truncatedSummary = truncateMessage(summary ?? lines[0], maxSummaryLength);
// Details: all lines up to a reasonable limit
const MAX_DETAIL_LINES = 20;
const detailsLines = lines.slice(0, MAX_DETAIL_LINES);
const details = detailsLines.join("\n");
const truncated = lines.length > MAX_DETAIL_LINES;
return {
summary: truncatedSummary,
details: truncated
? `${details}\n... and ${lines.length - MAX_DETAIL_LINES} more lines`
: details,
truncated,
};
}