mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
chore: initialize my-pi extension bundle
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
export function stripAnsi(text: string): string {
|
||||
return text
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
||||
.replace(/\x1b\][0-9;]*(?:\x07|\x1b\\)/g, "")
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
|
||||
}
|
||||
|
||||
export function stripAnsiFast(text: string): string {
|
||||
if (!text.includes("\x1b")) {
|
||||
return text;
|
||||
}
|
||||
return stripAnsi(text);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { matchesCommandPatterns } from "./command-detection.js";
|
||||
|
||||
interface BuildStats {
|
||||
compiled: number;
|
||||
errors: string[][];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const BUILD_COMMAND_PATTERNS = [
|
||||
/^cargo\s+(build|check)\b/,
|
||||
/^bun\s+build\b/,
|
||||
/^npm\s+run\s+build\b/,
|
||||
/^yarn\s+build\b/,
|
||||
/^pnpm\s+build\b/,
|
||||
/^(?:npx\s+)?tsc\b/,
|
||||
/^make\b/,
|
||||
/^cmake\b/,
|
||||
/^gradle\b/,
|
||||
/^mvn\b/,
|
||||
/^go\s+(build|install)\b/,
|
||||
/^python\s+setup\.py\s+build\b/,
|
||||
/^pip\s+install\b/,
|
||||
] as const;
|
||||
|
||||
const SKIP_PATTERNS = [
|
||||
/^\s*Compiling\s+/,
|
||||
/^\s*Checking\s+/,
|
||||
/^\s*Downloading\s+/,
|
||||
/^\s*Downloaded\s+/,
|
||||
/^\s*Fetching\s+/,
|
||||
/^\s*Fetched\s+/,
|
||||
/^\s*Updating\s+/,
|
||||
/^\s*Updated\s+/,
|
||||
/^\s*Building\s+/,
|
||||
/^\s*Generated\s+/,
|
||||
/^\s*Creating\s+/,
|
||||
/^\s*Running\s+/,
|
||||
];
|
||||
|
||||
const ERROR_START_PATTERNS = [/^error\[/, /^error:/, /^\[ERROR\]/, /^FAIL/];
|
||||
const WARNING_PATTERNS = [/^warning:/, /^\[WARNING\]/, /^warn:/];
|
||||
|
||||
function isSkipLine(line: string): boolean {
|
||||
return SKIP_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
function isErrorStart(line: string): boolean {
|
||||
return ERROR_START_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
function isWarning(line: string): boolean {
|
||||
return WARNING_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
export function isBuildCommand(command: string | undefined | null): boolean {
|
||||
return matchesCommandPatterns(command, BUILD_COMMAND_PATTERNS);
|
||||
}
|
||||
|
||||
export function filterBuildOutput(output: string, command: string | undefined | null): string | null {
|
||||
if (!isBuildCommand(command)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = output.split("\n");
|
||||
const stats: BuildStats = {
|
||||
compiled: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
let inErrorBlock = false;
|
||||
let currentError: string[] = [];
|
||||
let blankCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.match(/^\s*(Compiling|Checking|Building)\s+/)) {
|
||||
stats.compiled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSkipLine(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isErrorStart(line)) {
|
||||
if (inErrorBlock && currentError.length > 0) {
|
||||
stats.errors.push([...currentError]);
|
||||
}
|
||||
inErrorBlock = true;
|
||||
currentError = [line];
|
||||
blankCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWarning(line)) {
|
||||
stats.warnings.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inErrorBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
blankCount++;
|
||||
if (blankCount >= 2 && currentError.length > 3) {
|
||||
stats.errors.push([...currentError]);
|
||||
inErrorBlock = false;
|
||||
currentError = [];
|
||||
} else {
|
||||
currentError.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.match(/^\s/) || line.match(/^-->/)) {
|
||||
currentError.push(line);
|
||||
blankCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.errors.push([...currentError]);
|
||||
inErrorBlock = false;
|
||||
currentError = [];
|
||||
}
|
||||
|
||||
if (inErrorBlock && currentError.length > 0) {
|
||||
stats.errors.push(currentError);
|
||||
}
|
||||
|
||||
if (stats.errors.length === 0 && stats.warnings.length === 0) {
|
||||
return `[OK] Build successful (${stats.compiled} units compiled)`;
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
if (stats.errors.length > 0) {
|
||||
result.push(`[ERROR] ${stats.errors.length} error(s):`);
|
||||
for (const error of stats.errors.slice(0, 5)) {
|
||||
result.push(...error.slice(0, 10));
|
||||
if (error.length > 10) {
|
||||
result.push(" ...");
|
||||
}
|
||||
}
|
||||
if (stats.errors.length > 5) {
|
||||
result.push(`... and ${stats.errors.length - 5} more errors`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.warnings.length > 0) {
|
||||
result.push(`\n[WARN] ${stats.warnings.length} warning(s)`);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const ENV_PREFIX_PATTERN = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*/;
|
||||
const CHAIN_OPERATORS = ["&&", "||", ";", "|"] as const;
|
||||
|
||||
function sliceFirstSegment(command: string): string {
|
||||
let cutIndex = -1;
|
||||
for (const operator of CHAIN_OPERATORS) {
|
||||
const index = command.indexOf(operator);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
if (cutIndex === -1 || index < cutIndex) {
|
||||
cutIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (cutIndex === -1) {
|
||||
return command;
|
||||
}
|
||||
return command.slice(0, cutIndex);
|
||||
}
|
||||
|
||||
export function normalizeCommandForDetection(command: string | undefined | null): string | null {
|
||||
if (typeof command !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstNonEmptyLine = command
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstNonEmptyLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutEnvPrefix = firstNonEmptyLine.replace(ENV_PREFIX_PATTERN, "").trim();
|
||||
if (!withoutEnvPrefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstSegment = sliceFirstSegment(withoutEnvPrefix).trim().toLowerCase();
|
||||
return firstSegment || null;
|
||||
}
|
||||
|
||||
export function matchesCommandPatterns(
|
||||
command: string | undefined | null,
|
||||
patterns: readonly RegExp[],
|
||||
): boolean {
|
||||
const normalized = normalizeCommandForDetection(command);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return patterns.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
|
||||
|
||||
const GIT_COMMAND_PATTERNS = [/^git\s+(diff|status|log|show|stash)\b/] as const;
|
||||
const RAW_GIT_DIFF_PATTERN = /^diff --git /m;
|
||||
const RAW_GIT_STATUS_PATTERN = /^(?:## |(?:M|A|D|R|C|U|\?| )\S)/m;
|
||||
|
||||
export function isGitCommand(command: string | undefined | null): boolean {
|
||||
return matchesCommandPatterns(command, GIT_COMMAND_PATTERNS);
|
||||
}
|
||||
|
||||
export function compactDiff(output: string, maxLines = 50): string {
|
||||
const lines = output.split("\n");
|
||||
const result: string[] = [];
|
||||
let currentFile = "";
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let inHunk = false;
|
||||
let hunkLines = 0;
|
||||
const maxHunkLines = 10;
|
||||
|
||||
for (const line of lines) {
|
||||
if (result.length >= maxLines) {
|
||||
result.push("\n... (more changes truncated)");
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.startsWith("diff --git")) {
|
||||
if (currentFile && (added > 0 || removed > 0)) {
|
||||
result.push(` +${added} -${removed}`);
|
||||
}
|
||||
|
||||
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
|
||||
currentFile = match?.[2] ?? "unknown";
|
||||
result.push(`\n> ${currentFile}`);
|
||||
added = 0;
|
||||
removed = 0;
|
||||
inHunk = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
inHunk = true;
|
||||
hunkLines = 0;
|
||||
const hunkInfo = line.match(/@@ .+ @@/)?.[0] ?? "@@";
|
||||
result.push(` ${hunkInfo}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
added++;
|
||||
if (hunkLines < maxHunkLines) {
|
||||
result.push(` ${line}`);
|
||||
hunkLines++;
|
||||
}
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
removed++;
|
||||
if (hunkLines < maxHunkLines) {
|
||||
result.push(` ${line}`);
|
||||
hunkLines++;
|
||||
}
|
||||
} else if (hunkLines < maxHunkLines && !line.startsWith("\\")) {
|
||||
if (hunkLines > 0) {
|
||||
result.push(` ${line}`);
|
||||
hunkLines++;
|
||||
}
|
||||
}
|
||||
|
||||
if (hunkLines === maxHunkLines) {
|
||||
result.push(" ... (truncated)");
|
||||
hunkLines++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile && (added > 0 || removed > 0)) {
|
||||
result.push(` +${added} -${removed}`);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
interface StatusStats {
|
||||
staged: number;
|
||||
modified: number;
|
||||
untracked: number;
|
||||
conflicts: number;
|
||||
stagedFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
untrackedFiles: string[];
|
||||
}
|
||||
|
||||
export function compactStatus(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
|
||||
if (lines.length === 0 || (lines.length === 1 && lines[0]?.trim() === "")) {
|
||||
return "Clean working tree";
|
||||
}
|
||||
|
||||
const stats: StatusStats = {
|
||||
staged: 0,
|
||||
modified: 0,
|
||||
untracked: 0,
|
||||
conflicts: 0,
|
||||
stagedFiles: [],
|
||||
modifiedFiles: [],
|
||||
untrackedFiles: [],
|
||||
};
|
||||
|
||||
let branchName = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("##")) {
|
||||
const match = line.match(/## (.+)/);
|
||||
if (match?.[1]) {
|
||||
branchName = match[1].split("...")[0] ?? match[1];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = line.slice(0, 2);
|
||||
const filename = line.slice(3);
|
||||
const indexStatus = status[0];
|
||||
const worktreeStatus = status[1];
|
||||
|
||||
if (["M", "A", "D", "R", "C"].includes(indexStatus)) {
|
||||
stats.staged++;
|
||||
stats.stagedFiles.push(filename);
|
||||
}
|
||||
|
||||
if (indexStatus === "U") {
|
||||
stats.conflicts++;
|
||||
}
|
||||
|
||||
if (["M", "D"].includes(worktreeStatus)) {
|
||||
stats.modified++;
|
||||
stats.modifiedFiles.push(filename);
|
||||
}
|
||||
|
||||
if (status === "??") {
|
||||
stats.untracked++;
|
||||
stats.untrackedFiles.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
let result = `Branch: ${branchName}\n`;
|
||||
|
||||
if (stats.staged > 0) {
|
||||
result += `Staged: ${stats.staged} files\n`;
|
||||
for (const file of stats.stagedFiles.slice(0, 5)) {
|
||||
result += ` ${file}\n`;
|
||||
}
|
||||
if (stats.staged > 5) {
|
||||
result += ` ... +${stats.staged - 5} more\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.modified > 0) {
|
||||
result += `Modified: ${stats.modified} files\n`;
|
||||
for (const file of stats.modifiedFiles.slice(0, 5)) {
|
||||
result += ` ${file}\n`;
|
||||
}
|
||||
if (stats.modified > 5) {
|
||||
result += ` ... +${stats.modified - 5} more\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.untracked > 0) {
|
||||
result += `Untracked: ${stats.untracked} files\n`;
|
||||
for (const file of stats.untrackedFiles.slice(0, 3)) {
|
||||
result += ` ${file}\n`;
|
||||
}
|
||||
if (stats.untracked > 3) {
|
||||
result += ` ... +${stats.untracked - 3} more\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.conflicts > 0) {
|
||||
result += `Conflicts: ${stats.conflicts} files\n`;
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
export function compactLog(output: string, limit = 20): string {
|
||||
const lines = output.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines.slice(0, limit)) {
|
||||
if (line.length > 80) {
|
||||
result.push(`${line.slice(0, 77)}...`);
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length > limit) {
|
||||
result.push(`... and ${lines.length - limit} more commits`);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
export function compactGitOutput(output: string, command: string | undefined | null): string | null {
|
||||
if (!isGitCommand(command)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeCommandForDetection(command);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("git diff")) {
|
||||
return RAW_GIT_DIFF_PATTERN.test(output) ? compactDiff(output) : null;
|
||||
}
|
||||
if (normalized.startsWith("git status")) {
|
||||
return RAW_GIT_STATUS_PATTERN.test(output) ? compactStatus(output) : null;
|
||||
}
|
||||
if (normalized.startsWith("git log")) {
|
||||
return compactLog(output);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { stripAnsiFast } from "./ansi.js";
|
||||
export { truncate } from "./truncate.js";
|
||||
export { filterBuildOutput } from "./build.js";
|
||||
export { aggregateTestOutput } from "./test-output.js";
|
||||
export { aggregateLinterOutput } from "./linter.js";
|
||||
export { detectLanguage, smartTruncate, filterSourceCode } from "./source.js";
|
||||
export { compactGitOutput } from "./git.js";
|
||||
export { groupSearchResults } from "./search.js";
|
||||
@@ -0,0 +1,151 @@
|
||||
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
|
||||
import { compactPath } from "./path-utils.js";
|
||||
|
||||
const LINTER_COMMAND_PATTERNS = [
|
||||
/^(?:pnpm\s+)?(?:npx\s+)?eslint\b/,
|
||||
/^(?:npx\s+)?prettier\b/,
|
||||
/^ruff\b/,
|
||||
/^pylint\b/,
|
||||
/^mypy\b/,
|
||||
/^flake8\b/,
|
||||
/^black\b/,
|
||||
/^cargo\s+clippy\b/,
|
||||
/^golangci-lint\b/,
|
||||
] as const;
|
||||
|
||||
interface Issue {
|
||||
severity: "ERROR" | "WARNING";
|
||||
rule: string;
|
||||
file: string;
|
||||
line?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function isLinterCommand(command: string | undefined | null): boolean {
|
||||
return matchesCommandPatterns(command, LINTER_COMMAND_PATTERNS);
|
||||
}
|
||||
|
||||
function parseLine(line: string): Issue | null {
|
||||
const fileLinePattern = /^(.+):(\d+):(\d+):\s*(.+)$/;
|
||||
const rustPattern = /^(error|warning):\s*(.+?)\s+at\s+(.+):(\d+):(\d+)$/;
|
||||
|
||||
const fileLineMatch = line.match(fileLinePattern);
|
||||
if (fileLineMatch) {
|
||||
const file = fileLineMatch[1] ?? "unknown";
|
||||
const lineNumber = Number.parseInt(fileLineMatch[2] ?? "0", 10);
|
||||
const content = fileLineMatch[4] ?? line;
|
||||
const severity = /warning/i.test(content) ? "WARNING" : "ERROR";
|
||||
const rule = content.match(/\[(.+?)\]$/)?.[1] ?? "unknown";
|
||||
return {
|
||||
severity,
|
||||
rule,
|
||||
file,
|
||||
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
|
||||
message: content,
|
||||
};
|
||||
}
|
||||
|
||||
const rustMatch = line.match(rustPattern);
|
||||
if (rustMatch) {
|
||||
const severity = (rustMatch[1]?.toUpperCase() ?? "ERROR") as "ERROR" | "WARNING";
|
||||
const message = rustMatch[2] ?? line;
|
||||
const file = rustMatch[3] ?? "unknown";
|
||||
const lineNumber = Number.parseInt(rustMatch[4] ?? "0", 10);
|
||||
return {
|
||||
severity,
|
||||
rule: "unknown",
|
||||
file,
|
||||
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseIssues(output: string): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parsed = parseLine(line);
|
||||
if (parsed) {
|
||||
issues.push(parsed);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function detectLinterType(command: string | undefined | null): string {
|
||||
const normalized = normalizeCommandForDetection(command);
|
||||
if (!normalized) {
|
||||
return "Linter";
|
||||
}
|
||||
if (/(?:^|\s)eslint\b/.test(normalized)) return "ESLint";
|
||||
if (/^ruff\b/.test(normalized)) return "Ruff";
|
||||
if (/^pylint\b/.test(normalized)) return "Pylint";
|
||||
if (/^mypy\b/.test(normalized)) return "MyPy";
|
||||
if (/^flake8\b/.test(normalized)) return "Flake8";
|
||||
if (/clippy\b/.test(normalized)) return "Clippy";
|
||||
if (/^golangci-lint\b/.test(normalized)) return "GolangCI-Lint";
|
||||
if (/prettier\b/.test(normalized)) return "Prettier";
|
||||
return "Linter";
|
||||
}
|
||||
|
||||
export function aggregateLinterOutput(output: string, command: string | undefined | null): string | null {
|
||||
if (!isLinterCommand(command)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linterType = detectLinterType(command);
|
||||
const issues = parseIssues(output);
|
||||
|
||||
if (issues.length === 0) {
|
||||
return `[OK] ${linterType}: No issues found`;
|
||||
}
|
||||
|
||||
const errors = issues.filter((issue) => issue.severity === "ERROR").length;
|
||||
const warnings = issues.filter((issue) => issue.severity === "WARNING").length;
|
||||
|
||||
const byRule = new Map<string, number>();
|
||||
for (const issue of issues) {
|
||||
byRule.set(issue.rule, (byRule.get(issue.rule) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const byFile = new Map<string, Issue[]>();
|
||||
for (const issue of issues) {
|
||||
const existing = byFile.get(issue.file) ?? [];
|
||||
existing.push(issue);
|
||||
byFile.set(issue.file, existing);
|
||||
}
|
||||
|
||||
let result = `${linterType}: ${errors} errors, ${warnings} warnings in ${byFile.size} files\n`;
|
||||
result += "═══════════════════════════════════════\n";
|
||||
|
||||
result += "Top rules:\n";
|
||||
const sortedRules = Array.from(byRule.entries())
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, 10);
|
||||
for (const [rule, count] of sortedRules) {
|
||||
result += ` ${rule} (${count}x)\n`;
|
||||
}
|
||||
|
||||
result += "\nTop files:\n";
|
||||
const sortedFiles = Array.from(byFile.entries())
|
||||
.sort((left, right) => right[1].length - left[1].length)
|
||||
.slice(0, 10);
|
||||
|
||||
for (const [file, fileIssues] of sortedFiles) {
|
||||
result += ` ${compactPath(file, 40)} (${fileIssues.length} issues)\n`;
|
||||
const fileRules = new Map<string, number>();
|
||||
for (const issue of fileIssues) {
|
||||
fileRules.set(issue.rule, (fileRules.get(issue.rule) ?? 0) + 1);
|
||||
}
|
||||
const topRules = Array.from(fileRules.entries())
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, 3);
|
||||
for (const [rule, count] of topRules) {
|
||||
result += ` ${rule} (${count})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
function detectPathSeparator(path: string): "/" | "\\" {
|
||||
return path.includes("\\") && !path.includes("/") ? "\\" : "/";
|
||||
}
|
||||
|
||||
function detectPathPrefix(path: string, separator: "/" | "\\"): string {
|
||||
if (/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
return `${path.slice(0, 2)}${separator}`;
|
||||
}
|
||||
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) {
|
||||
const parts = path.split(/[\\/]+/).filter((part) => part.length > 0);
|
||||
if (parts.length >= 2) {
|
||||
return `${separator}${separator}${parts[0]}${separator}${parts[1]}${separator}`;
|
||||
}
|
||||
return `${separator}${separator}`;
|
||||
}
|
||||
|
||||
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||
return separator;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function joinPathSegments(prefix: string, separator: "/" | "\\", segments: string[]): string {
|
||||
if (segments.length === 0) {
|
||||
return prefix || "";
|
||||
}
|
||||
|
||||
const joined = segments.join(separator);
|
||||
return prefix ? `${prefix}${joined}` : joined;
|
||||
}
|
||||
|
||||
export function compactPath(path: string, maxLength: number): string {
|
||||
if (path.length <= maxLength) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (maxLength < 2) {
|
||||
return path.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const separator = detectPathSeparator(path);
|
||||
const prefix = detectPathPrefix(path, separator);
|
||||
const segments = path
|
||||
.slice(prefix.length)
|
||||
.split(/[\\/]+/)
|
||||
.filter((segment) => segment.length > 0);
|
||||
|
||||
const lastSegment = segments[segments.length - 1] ?? path.slice(-(maxLength - 1));
|
||||
const previousSegment = segments[segments.length - 2];
|
||||
|
||||
const candidates = [
|
||||
joinPathSegments(prefix, separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
|
||||
joinPathSegments("", separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
|
||||
joinPathSegments("", separator, ["…", lastSegment]),
|
||||
`…${path.slice(-(maxLength - 1))}`,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.length <= maxLength) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return `…${lastSegment.slice(-(maxLength - 1))}`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { compactPath } from "./path-utils.js";
|
||||
|
||||
interface SearchResult {
|
||||
file: string;
|
||||
lineNumber: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function groupSearchResults(output: string, maxResults = 50): string | null {
|
||||
const results: SearchResult[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^(.+?):(\d+)?:(.+)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
file: match[1] ?? "unknown",
|
||||
lineNumber: match[2] ?? "?",
|
||||
content: match[3] ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byFile = new Map<string, SearchResult[]>();
|
||||
for (const result of results) {
|
||||
const existing = byFile.get(result.file) ?? [];
|
||||
existing.push(result);
|
||||
byFile.set(result.file, existing);
|
||||
}
|
||||
|
||||
let outputText = `${results.length} matches in ${byFile.size} files:\n\n`;
|
||||
const sortedFiles = Array.from(byFile.entries()).sort((left, right) =>
|
||||
left[0].localeCompare(right[0]),
|
||||
);
|
||||
|
||||
let shown = 0;
|
||||
for (const [file, matches] of sortedFiles) {
|
||||
if (shown >= maxResults) {
|
||||
break;
|
||||
}
|
||||
outputText += `> ${compactPath(file, 50)} (${matches.length} matches):\n`;
|
||||
for (const match of matches.slice(0, 10)) {
|
||||
let cleaned = match.content.trim();
|
||||
if (cleaned.length > 70) {
|
||||
cleaned = `${cleaned.slice(0, 67)}...`;
|
||||
}
|
||||
outputText += ` ${match.lineNumber}: ${cleaned}\n`;
|
||||
shown++;
|
||||
}
|
||||
if (matches.length > 10) {
|
||||
outputText += ` +${matches.length - 10} more\n`;
|
||||
}
|
||||
outputText += "\n";
|
||||
}
|
||||
|
||||
if (results.length > shown) {
|
||||
outputText += `... +${results.length - shown} more\n`;
|
||||
}
|
||||
|
||||
return outputText;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
export type Language =
|
||||
| "typescript"
|
||||
| "javascript"
|
||||
| "python"
|
||||
| "rust"
|
||||
| "go"
|
||||
| "java"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "unknown";
|
||||
|
||||
const LANGUAGE_EXTENSIONS: Record<string, Language> = {
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".mjs": "javascript",
|
||||
".py": "python",
|
||||
".pyw": "python",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".hpp": "cpp",
|
||||
".cc": "cpp",
|
||||
};
|
||||
|
||||
interface CommentPatterns {
|
||||
line?: string;
|
||||
blockStart?: string;
|
||||
blockEnd?: string;
|
||||
docLine?: string;
|
||||
docBlockStart?: string;
|
||||
}
|
||||
|
||||
const COMMENT_PATTERNS: Record<Language, CommentPatterns> = {
|
||||
typescript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||
javascript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||
python: { line: "#", blockStart: '"""', blockEnd: '"""', docBlockStart: '"""' },
|
||||
rust: { line: "//", blockStart: "/*", blockEnd: "*/", docLine: "///", docBlockStart: "/**" },
|
||||
go: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||
java: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||
c: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||
cpp: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||
unknown: { line: "//", blockStart: "/*", blockEnd: "*/" },
|
||||
};
|
||||
|
||||
const IMPORT_PATTERN = /^(use\s+|import\s+|from\s+|require\(|#include)/;
|
||||
const SIGNATURE_PATTERN = /^(pub\s+)?(async\s+)?(fn|def|function|func|class|struct|enum|trait|interface|type)\s+\w+/;
|
||||
const CONST_PATTERN = /^(const|static|let|pub\s+const|pub\s+static)\s+/;
|
||||
|
||||
function getCodePortion(line: string, language: Language): string {
|
||||
const patterns = COMMENT_PATTERNS[language];
|
||||
let quote: '"' | "'" | "`" | null = null;
|
||||
let escaped = false;
|
||||
let code = "";
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index] ?? "";
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null) {
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patterns.line && line.startsWith(patterns.line, index)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (patterns.blockStart && patterns.blockEnd && line.startsWith(patterns.blockStart, index)) {
|
||||
const blockEndIndex = line.indexOf(patterns.blockEnd, index + patterns.blockStart.length);
|
||||
if (blockEndIndex === -1) {
|
||||
break;
|
||||
}
|
||||
index = blockEndIndex + patterns.blockEnd.length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
code += character;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
function countCodeBraces(line: string, language: Language): { open: number; close: number } {
|
||||
let open = 0;
|
||||
let close = 0;
|
||||
|
||||
for (const character of getCodePortion(line, language)) {
|
||||
if (character === "{") {
|
||||
open += 1;
|
||||
} else if (character === "}") {
|
||||
close += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { open, close };
|
||||
}
|
||||
|
||||
export function detectLanguage(filePath: string): Language {
|
||||
const lastDot = filePath.lastIndexOf(".");
|
||||
if (lastDot === -1) {
|
||||
return "unknown";
|
||||
}
|
||||
const extension = filePath.slice(lastDot).toLowerCase();
|
||||
return LANGUAGE_EXTENSIONS[extension] ?? "unknown";
|
||||
}
|
||||
|
||||
export function filterMinimal(content: string, language: Language): string {
|
||||
const patterns = COMMENT_PATTERNS[language];
|
||||
const lines = content.split("\n");
|
||||
const result: string[] = [];
|
||||
let inBlockComment = false;
|
||||
let inDocstring = false;
|
||||
let inUserscriptMetadataBlock = false;
|
||||
const userscriptMetadataStartPattern = /^\/\/\s*==\s*userscript\s*==$/i;
|
||||
const userscriptMetadataContentPattern = /^\/\/\s*@\w+/;
|
||||
const userscriptMetadataEndPattern = /^\/\/\s*==\s*\/userscript\s*==$/i;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const isUserscriptMetadataStart = userscriptMetadataStartPattern.test(trimmed);
|
||||
const isUserscriptMetadataContent = userscriptMetadataContentPattern.test(trimmed);
|
||||
const isUserscriptMetadataEnd = userscriptMetadataEndPattern.test(trimmed);
|
||||
|
||||
if (isUserscriptMetadataStart) {
|
||||
inUserscriptMetadataBlock = true;
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inUserscriptMetadataBlock) {
|
||||
result.push(line);
|
||||
if (isUserscriptMetadataEnd) {
|
||||
inUserscriptMetadataBlock = false;
|
||||
} else if (isUserscriptMetadataContent) {
|
||||
// Preserve metadata key/value lines (e.g. // @name) within the userscript block.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patterns.blockStart && patterns.blockEnd) {
|
||||
if (
|
||||
!inDocstring &&
|
||||
trimmed.includes(patterns.blockStart) &&
|
||||
!(patterns.docBlockStart && trimmed.startsWith(patterns.docBlockStart))
|
||||
) {
|
||||
inBlockComment = true;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (trimmed.includes(patterns.blockEnd)) {
|
||||
inBlockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (language === "python" && trimmed.startsWith('"""')) {
|
||||
inDocstring = !inDocstring;
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDocstring) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patterns.line && trimmed.startsWith(patterns.line)) {
|
||||
if (patterns.docLine && trimmed.startsWith(patterns.docLine)) {
|
||||
result.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
result.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
return result
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function filterAggressive(content: string, language: Language): string {
|
||||
const minimal = filterMinimal(content, language);
|
||||
const lines = minimal.split("\n");
|
||||
const result: string[] = [];
|
||||
let braceDepth = 0;
|
||||
let inImplementation = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (IMPORT_PATTERN.test(trimmed)) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SIGNATURE_PATTERN.test(trimmed)) {
|
||||
result.push(line);
|
||||
inImplementation = true;
|
||||
braceDepth = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const braces = countCodeBraces(line, language);
|
||||
const codeTrimmed = getCodePortion(line, language).trim();
|
||||
|
||||
if (inImplementation) {
|
||||
braceDepth += braces.open;
|
||||
braceDepth -= braces.close;
|
||||
|
||||
if (braceDepth <= 1 && (codeTrimmed === "{" || codeTrimmed === "}" || codeTrimmed.endsWith("{"))) {
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
if (braceDepth <= 0) {
|
||||
inImplementation = false;
|
||||
if (trimmed.length > 0 && trimmed !== "}") {
|
||||
result.push(" // ... implementation");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CONST_PATTERN.test(trimmed)) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n").trim();
|
||||
}
|
||||
|
||||
export function smartTruncate(content: string, maxLines: number, _language: Language): string {
|
||||
const lines = content.split("\n");
|
||||
if (lines.length <= maxLines) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
let keptLines = 0;
|
||||
let skippedSection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const isImportant =
|
||||
SIGNATURE_PATTERN.test(trimmed) ||
|
||||
IMPORT_PATTERN.test(trimmed) ||
|
||||
trimmed.startsWith("pub ") ||
|
||||
trimmed.startsWith("export ") ||
|
||||
trimmed === "}" ||
|
||||
trimmed === "{";
|
||||
|
||||
if (isImportant || keptLines < maxLines / 2) {
|
||||
if (skippedSection) {
|
||||
result.push(` // ... ${lines.length - keptLines} lines omitted`);
|
||||
skippedSection = false;
|
||||
}
|
||||
result.push(line);
|
||||
keptLines += 1;
|
||||
} else {
|
||||
skippedSection = true;
|
||||
}
|
||||
|
||||
if (keptLines >= maxLines - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedSection || keptLines < lines.length) {
|
||||
result.push(`// ... ${lines.length - keptLines} more lines (total: ${lines.length})`);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
export function filterSourceCode(
|
||||
content: string,
|
||||
language: Language,
|
||||
level: "none" | "minimal" | "aggressive",
|
||||
): string {
|
||||
switch (level) {
|
||||
case "none":
|
||||
return content;
|
||||
case "minimal":
|
||||
return filterMinimal(content, language);
|
||||
case "aggressive":
|
||||
return filterAggressive(content, language);
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { matchesCommandPatterns } from "./command-detection.js";
|
||||
|
||||
interface TestSummary {
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
const TEST_COMMAND_PATTERNS = [
|
||||
/^npm\s+test\b/,
|
||||
/^pnpm\s+test\b/,
|
||||
/^yarn\s+test\b/,
|
||||
/^bun\s+test\b/,
|
||||
/^cargo\s+test\b/,
|
||||
/^go\s+test\b/,
|
||||
/^pytest\b/,
|
||||
/^python\s+-m\s+pytest\b/,
|
||||
/^(?:pnpm\s+)?(?:npx\s+)?vitest\b/,
|
||||
/^(?:npx\s+)?jest\b/,
|
||||
/^mocha\b/,
|
||||
/^ava\b/,
|
||||
/^tap\b/,
|
||||
] as const;
|
||||
|
||||
const TEST_RESULT_PATTERNS = [
|
||||
/test result:\s*(\w+)\.\s*(\d+)\s*passed;\s*(\d+)\s*failed;/,
|
||||
/(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
|
||||
/(\d+)\s*pass(?:,\s*(\d+)\s*fail)?(?:,\s*(\d+)\s*skip)?/i,
|
||||
/tests?:\s*(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
|
||||
];
|
||||
|
||||
const FAILURE_START_PATTERNS = [
|
||||
/^FAIL\s+/,
|
||||
/^FAILED\s+/,
|
||||
/^\s*●\s+/,
|
||||
/^\s*✕\s+/,
|
||||
/test\s+\w+\s+\.\.\.\s*FAILED/,
|
||||
/thread\s+'\w+'\s+panicked/,
|
||||
];
|
||||
const FALLBACK_PASS_PATTERN = /(?:\b(?:ok|PASS)\b|[✓✔])/;
|
||||
const FALLBACK_FAIL_PATTERN = /(?:\b(?:FAIL|fail)\b|[✗✕])/;
|
||||
|
||||
function isFailureStart(line: string): boolean {
|
||||
return FAILURE_START_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
function extractTestStats(output: string): Partial<TestSummary> {
|
||||
for (const pattern of TEST_RESULT_PATTERNS) {
|
||||
const match = output.match(pattern);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
passed: Number.parseInt(match[1] ?? "0", 10) || 0,
|
||||
failed: Number.parseInt(match[2] ?? "0", 10) || 0,
|
||||
skipped: Number.parseInt(match[3] ?? "0", 10) || 0,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function isTestCommand(command: string | undefined | null): boolean {
|
||||
return matchesCommandPatterns(command, TEST_COMMAND_PATTERNS);
|
||||
}
|
||||
|
||||
export function aggregateTestOutput(output: string, command: string | undefined | null): string | null {
|
||||
if (!isTestCommand(command)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = output.split("\n");
|
||||
const summary: TestSummary = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
const stats = extractTestStats(output);
|
||||
summary.passed = stats.passed ?? 0;
|
||||
summary.failed = stats.failed ?? 0;
|
||||
summary.skipped = stats.skipped ?? 0;
|
||||
|
||||
if (summary.passed === 0 && summary.failed === 0) {
|
||||
for (const line of lines) {
|
||||
if (FALLBACK_PASS_PATTERN.test(line)) {
|
||||
summary.passed++;
|
||||
}
|
||||
if (FALLBACK_FAIL_PATTERN.test(line)) {
|
||||
summary.failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.failed > 0) {
|
||||
let inFailure = false;
|
||||
let currentFailure: string[] = [];
|
||||
let blankCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (isFailureStart(line)) {
|
||||
if (inFailure && currentFailure.length > 0) {
|
||||
summary.failures.push(currentFailure.join("\n"));
|
||||
}
|
||||
inFailure = true;
|
||||
currentFailure = [line];
|
||||
blankCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inFailure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
blankCount++;
|
||||
if (blankCount >= 2 && currentFailure.length > 3) {
|
||||
summary.failures.push(currentFailure.join("\n"));
|
||||
inFailure = false;
|
||||
currentFailure = [];
|
||||
} else {
|
||||
currentFailure.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.match(/^\s/) || line.match(/^-/)) {
|
||||
currentFailure.push(line);
|
||||
blankCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.failures.push(currentFailure.join("\n"));
|
||||
inFailure = false;
|
||||
currentFailure = [];
|
||||
}
|
||||
|
||||
if (inFailure && currentFailure.length > 0) {
|
||||
summary.failures.push(currentFailure.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
const result: string[] = ["Test Results:"];
|
||||
result.push(` PASS: ${summary.passed} passed`);
|
||||
if (summary.failed > 0) {
|
||||
result.push(` FAIL: ${summary.failed} failed`);
|
||||
}
|
||||
if (summary.skipped > 0) {
|
||||
result.push(` SKIP: ${summary.skipped} skipped`);
|
||||
}
|
||||
|
||||
if (summary.failed > 0 && summary.failures.length > 0) {
|
||||
result.push("\n Failures:");
|
||||
for (const failure of summary.failures.slice(0, 5)) {
|
||||
const failureLines = failure.split("\n");
|
||||
const firstLine = failureLines[0] ?? "";
|
||||
result.push(` - ${firstLine.slice(0, 70)}${firstLine.length > 70 ? "..." : ""}`);
|
||||
for (const detailLine of failureLines.slice(1, 4)) {
|
||||
if (detailLine.trim()) {
|
||||
result.push(` ${detailLine.slice(0, 65)}${detailLine.length > 65 ? "..." : ""}`);
|
||||
}
|
||||
}
|
||||
if (failureLines.length > 4) {
|
||||
result.push(` ... (${failureLines.length - 4} more lines)`);
|
||||
}
|
||||
}
|
||||
if (summary.failures.length > 5) {
|
||||
result.push(` ... and ${summary.failures.length - 5} more failures`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (maxLength < 3) {
|
||||
return "...";
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
Reference in New Issue
Block a user