Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 76 additions & 39 deletions extensions/sweeper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,11 @@ export default function sweeper({ tool, widget }: any) {

// Parse with three regex patterns matching Go CLI convention
const golangciPattern =
/^(.+?):(\d+):(\d+):\s+(.+)\s+\((\w[\w-]*)\)$/;
/^(.+?):(\d+):(\d+):\s+(.+)\s+\(([@\w][\w./@-]*)\)$/;
const genericPattern = /^(.+?):(\d+):(\d+):\s+(.+)$/;
const minimalPattern = /^(.+?):(\d+):\s+(.+)$/;
const eslintStylishIssue =
/^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$/;

const issues: Array<{
file: string;
Expand All @@ -241,46 +243,81 @@ export default function sweeper({ tool, widget }: any) {
linter: string;
}> = [];

for (const line of rawOutput.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;

let m: RegExpMatchArray | null;

m = trimmed.match(golangciPattern);
if (m) {
issues.push({
file: m[1],
line: parseInt(m[2], 10),
col: parseInt(m[3], 10),
message: m[4],
linter: m[5],
});
continue;
}

m = trimmed.match(genericPattern);
if (m) {
issues.push({
file: m[1],
line: parseInt(m[2], 10),
col: parseInt(m[3], 10),
message: m[4],
linter: "custom",
});
continue;
// Try ESLint stylish (multi-line block) format first
{
let currentFile = "";
for (const line of rawOutput.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (
trimmed.includes("problem") &&
(trimmed.includes("\u2716") ||
(trimmed.includes("error") && trimmed.includes("warning")))
)
continue;

const sm = line.match(eslintStylishIssue);
if (sm && currentFile) {
issues.push({
file: currentFile,
line: parseInt(sm[1], 10),
col: parseInt(sm[2], 10),
message: sm[4].trim(),
linter: sm[5],
});
continue;
}

// File header: non-indented, non-empty
if (line === trimmed && trimmed.length > 0 && !trimmed.startsWith("\u2716")) {
currentFile = trimmed;
}
}
}

m = trimmed.match(minimalPattern);
if (m) {
issues.push({
file: m[1],
line: parseInt(m[2], 10),
col: 0,
message: m[3],
linter: "custom",
});
continue;
// If stylish parse found nothing, fall back to line-by-line patterns
if (issues.length === 0) {
for (const line of rawOutput.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;

let m: RegExpMatchArray | null;

m = trimmed.match(golangciPattern);
if (m) {
issues.push({
file: m[1],
line: parseInt(m[2], 10),
col: parseInt(m[3], 10),
message: m[4],
linter: m[5],
});
continue;
}

m = trimmed.match(genericPattern);
if (m) {
issues.push({
file: m[1],
line: parseInt(m[2], 10),
col: parseInt(m[3], 10),
message: m[4],
linter: "custom",
});
continue;
}

m = trimmed.match(minimalPattern);
if (m) {
issues.push({
file: m[1],
line: parseInt(m[2], 10),
col: 0,
message: m[3],
linter: "custom",
});
continue;
}
}
}

Expand Down
60 changes: 59 additions & 1 deletion pkg/linter/linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ type ParseResult struct {

var (
// golangci-lint format: file:line:col: message (linter)
golangciPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)\s+\((\w[\w-]*)\)$`)
// Linter name supports @-scoped rules like @typescript-eslint/no-unused-vars
golangciPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)\s+\(([@\w][\w./@-]*)\)$`)
// ESLint stylish issue line: " line:col error|warning message rule-name"
eslintStylishIssue = regexp.MustCompile(`^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$`)
// generic file:line:col: message
genericPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)$`)
// minimal file:line: message
Expand All @@ -36,6 +39,14 @@ var (

func ParseOutput(raw string) ParseResult {
result := ParseResult{RawOutput: raw}

// Try ESLint stylish (multi-line block) format first
if issues := parseESLintStylish(raw); len(issues) > 0 {
result.Issues = issues
result.Parsed = true
return result
}

scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
Expand All @@ -59,6 +70,53 @@ func ParseOutput(raw string) ParseResult {
return result
}

// parseESLintStylish parses ESLint's default "stylish" multi-line format:
//
// /path/to/file.js
// 2:10 error 'foo' is not defined no-undef
// 5:1 warning Unexpected console statement no-console
//
// ✖ 2 problems (1 error, 1 warning)
func parseESLintStylish(raw string) []Issue {
var issues []Issue
var currentFile string

for _, line := range strings.Split(raw, "\n") {
// Skip empty lines and summary lines (e.g. "✖ 2 problems...")
if strings.TrimSpace(line) == "" {
continue
}
if strings.Contains(line, "problem") && (strings.Contains(line, "✖") || strings.Contains(line, "error") && strings.Contains(line, "warning")) {
continue
}

// Issue line: indented with "line:col severity message rule"
if m := eslintStylishIssue.FindStringSubmatch(line); m != nil {
if currentFile == "" {
continue
}
lineNum, _ := strconv.Atoi(m[1])
col, _ := strconv.Atoi(m[2])
issues = append(issues, Issue{
File: currentFile,
Line: lineNum,
Col: col,
Message: strings.TrimSpace(m[4]),
Linter: m[5],
})
continue
}

// File header: non-indented line that looks like a path
trimmed := strings.TrimSpace(line)
if line == trimmed && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "✖") {
currentFile = trimmed
}
}

return issues
}

func parseGolangci(line string) (Issue, bool) {
m := golangciPattern.FindStringSubmatch(line)
if m == nil {
Expand Down
59 changes: 59 additions & 0 deletions pkg/linter/linter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,65 @@ func TestParseOutputESLint(t *testing.T) {
}
}

func TestParseOutputESLintStylish(t *testing.T) {
data, err := os.ReadFile("../../testdata/sample_eslint_stylish_output.txt")
if err != nil {
t.Fatal(err)
}
result := ParseOutput(string(data))
if !result.Parsed {
t.Fatal("expected Parsed to be true for ESLint stylish output")
}
if len(result.Issues) != 4 {
t.Fatalf("expected 4 issues, got %d", len(result.Issues))
}

// Verify first issue
first := result.Issues[0]
if first.File != "src/App.tsx" {
t.Errorf("expected file src/App.tsx, got %s", first.File)
}
if first.Line != 2 {
t.Errorf("expected line 2, got %d", first.Line)
}
if first.Col != 10 {
t.Errorf("expected col 10, got %d", first.Col)
}
if first.Linter != "@typescript-eslint/no-unused-vars" {
t.Errorf("expected linter @typescript-eslint/no-unused-vars, got %s", first.Linter)
}

// Verify issue from a different file
third := result.Issues[2]
if third.File != "src/components/Header.tsx" {
t.Errorf("expected file src/components/Header.tsx, got %s", third.File)
}
if third.Linter != "@typescript-eslint/explicit-function-return-type" {
t.Errorf("expected linter @typescript-eslint/explicit-function-return-type, got %s", third.Linter)
}

// Verify last issue uses simple rule name
last := result.Issues[3]
if last.Linter != "no-console" {
t.Errorf("expected linter no-console, got %s", last.Linter)
}
}

func TestParseOutputESLintStylishOrphanLine(t *testing.T) {
// Issue line appearing before any file header should be skipped
raw := " 1:5 error Unexpected var no-var\n\nsrc/app.ts\n 3:1 warning Missing semicolon semi\n"
result := ParseOutput(raw)
if !result.Parsed {
t.Fatal("expected Parsed to be true")
}
if len(result.Issues) != 1 {
t.Fatalf("expected 1 issue (orphan skipped), got %d", len(result.Issues))
}
if result.Issues[0].File != "src/app.ts" {
t.Errorf("expected file src/app.ts, got %s", result.Issues[0].File)
}
}

func TestParseOutputPylint(t *testing.T) {
data, err := os.ReadFile("../../testdata/sample_pylint_output.txt")
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions testdata/sample_eslint_stylish_output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
src/App.tsx
2:10 error 'useState' is defined but never used @typescript-eslint/no-unused-vars
34:10 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any

src/components/Header.tsx
8:1 error Missing return type on function @typescript-eslint/explicit-function-return-type

src/utils/helpers.ts
22:3 warning Unexpected console statement no-console

✖ 4 problems (2 errors, 2 warnings)
Loading