From 350ad22b7d64282104f40a0be7bc90d53c4255ab Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 20:16:21 +0500 Subject: [PATCH 01/12] feat: html output --- internal/hash/hash.go | 1 + internal/load/load.go | 18 +- internal/load/load_test.go | 24 +++ internal/report/report.go | 292 ++++++++++++++++++++++++++++++++- internal/report/report_test.go | 43 ++++- main.go | 9 +- 6 files changed, 380 insertions(+), 7 deletions(-) diff --git a/internal/hash/hash.go b/internal/hash/hash.go index f8f21b4..dc7df8d 100644 --- a/internal/hash/hash.go +++ b/internal/hash/hash.go @@ -23,6 +23,7 @@ type FuncInfo struct { StmtSeq []uint64 // per-statement hashes for similarity comparison NumStmts int // total statement count (excluding blank lines) NumLines int // line span of the function body + Source string // original function source, used by rich reports } // Hasher computes structural hashes of AST nodes. diff --git a/internal/load/load.go b/internal/load/load.go index 0d63f38..b50ab60 100644 --- a/internal/load/load.go +++ b/internal/load/load.go @@ -1,6 +1,7 @@ package load import ( + "bytes" "go/ast" "go/parser" "go/token" @@ -79,7 +80,12 @@ func parseFile(path string, fset *token.FileSet, hasher *hash.Hasher, inp *cmd.L return nil } - f, err := parser.ParseFile(fset, path, nil, 0) + src, err := os.ReadFile(path) + if err != nil { + return err + } + + f, err := parser.ParseFile(fset, path, src, 0) if err != nil { // skip unparseable files return nil @@ -99,6 +105,7 @@ func parseFile(path string, fset *token.FileSet, hasher *hash.Hasher, inp *cmd.L } info := hasher.HashFunc(pkg, path, fn) + info.Source = sourceSpan(src, fset, fn) if info.Name == "" { continue } @@ -107,3 +114,12 @@ func parseFile(path string, fset *token.FileSet, hasher *hash.Hasher, inp *cmd.L return nil } + +func sourceSpan(src []byte, fset *token.FileSet, fn *ast.FuncDecl) string { + start := fset.Position(fn.Pos()).Offset + end := fset.Position(fn.End()).Offset + if start < 0 || end < start || end > len(src) { + return "" + } + return string(bytes.TrimRight(src[start:end], "\n")) +} diff --git a/internal/load/load_test.go b/internal/load/load_test.go index 76f9723..83c7b9e 100644 --- a/internal/load/load_test.go +++ b/internal/load/load_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" "github.com/hashmap-kz/godedup/internal/cmd" @@ -164,6 +165,29 @@ func Vendored() int { } } +func TestLoadStoresFunctionSource(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "source.go") + writeFile(t, file, `package sample +func Source() int { + a := 1 + b := 2 + return a + b +} +`) + + result, err := Load([]string{file}, emptyLoadInput()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 1 { + t.Fatalf("len(Funcs) = %d, want 1", len(result.Funcs)) + } + if got := result.Funcs[0].Source; !strings.Contains(got, "func Source() int") || !strings.Contains(got, "return a + b") { + t.Fatalf("Source was not captured correctly:\n%s", got) + } +} + func TestLoadSingleFile(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "single.go") diff --git a/internal/report/report.go b/internal/report/report.go index 8955f00..cd71a56 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,6 +2,7 @@ package report import ( "fmt" + "html" "io" "sort" "strings" @@ -221,9 +222,296 @@ func Print(w io.Writer, clones []Clone, cwd string) { } fmtx.Fprintln(w) } +} - fmtx.Fprintf(w, "suggestion: extract shared logic into a common function\n") - fmtx.Fprintf(w, " or use generics if types differ\n") +// PrintHTML writes a self-contained HTML report. +func PrintHTML(w io.Writer, clones []Clone, cwd string) { + exact, near, funcs := cloneStats(clones) + + fmtx.Fprint(w, ` + + + + +godedup report + + + +
+`) + + fmtx.Fprintf(w, `
+
+
+

godedup report

+

Structural duplicate detection for Go

+
+
+ %d groups + %d exact + %d near + %d functions +
+
+
+`, len(clones), exact, near, funcs) + + if len(clones) == 0 { + fmtx.Fprint(w, `
No structural duplicates found.
`) + fmtx.Fprint(w, "\n
\n\n\n") + return + } + + for i, clone := range clones { + writeHTMLCloneGroup(w, i+1, clone, cwd) + } + + fmtx.Fprint(w, "\n\n\n") +} + +func cloneStats(clones []Clone) (exact int, near int, funcs int) { + for _, c := range clones { + funcs += len(c.Funcs) + if c.Exact { + exact++ + } else { + near++ + } + } + return exact, near, funcs +} + +func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { + kind := "EXACT" + sim := "100%" + kindClass := "exact" + if !clone.Exact { + kind = "NEAR" + sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) + kindClass = "near" + } + + sorted := sortedFuncs(clone.Funcs) + className := "funcs-many" + if len(sorted) == 2 { + className = "funcs-2" + } + + fmtx.Fprintf(w, `
+
+
+ #%d + %s + %s +
+
%d functions
+
+
+`, className, groupNo, kindClass, kind, sim, len(sorted)) + + for _, f := range sorted { + writeHTMLFunctionCard(w, f, cwd) + } + + fmtx.Fprint(w, "
\n
\n") +} + +func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { + loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) + fmtx.Fprintf(w, `
+
+
%s
+
%s · %d stmts · %d lines
+
+
+`, html.EscapeString(f.Name), html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc), f.NumStmts, f.NumLines) + + lines := strings.Split(f.Source, "\n") + if f.Source == "" { + lines = []string{"source unavailable"} + } + for i, line := range lines { + lineNo := f.Line + i + fmtx.Fprintf(w, `
%d%s
+`, html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) + } + + fmtx.Fprint(w, "
\n
\n") +} + +func fileURL(path string, line int) string { + return fmt.Sprintf("file://%s:%d", path, line) +} + +func sortedFuncs(funcs []hash.FuncInfo) []hash.FuncInfo { + sorted := make([]hash.FuncInfo, len(funcs)) + copy(sorted, funcs) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].File != sorted[b].File { + return sorted[a].File < sorted[b].File + } + return sorted[a].Line < sorted[b].Line + }) + return sorted } // PrintJSON writes machine-readable JSON output. diff --git a/internal/report/report_test.go b/internal/report/report_test.go index b3f0f21..781d56c 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -142,12 +142,14 @@ func TestPrintHumanReadable(t *testing.T) { "a.go:10 (3 stmts, 7 lines)", "pkg.B", "b.go:20 (3 stmts, 7 lines)", - "suggestion: extract shared logic into a common function", } { if !strings.Contains(got, want) { t.Fatalf("Print() missing %q in:\n%s", want, got) } } + if strings.Contains(got, "suggestion:") { + t.Fatalf("Print() contains superfluous suggestion:\n%s", got) + } if strings.Index(got, "pkg.A") > strings.Index(got, "pkg.B") { t.Fatalf("functions are not sorted by file/line:\n%s", got) } @@ -184,6 +186,45 @@ func TestPrintTable(t *testing.T) { } } +func TestPrintHTML(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.B", "/repo/b.go", 20, 3, 7, 100, 1, 2, 3), + funcInfo("pkg.A", "/repo/a.go", 10, 3, 7, 100, 1, 2, 3), + }, + }} + clones[0].Funcs[0].Source = "func B() int {\n\tx := 1\n\ty := 2\n\treturn x + y\n}" + clones[0].Funcs[1].Source = "func A() int {\n\tx := 1\n\ty := 2\n\treturn x + y\n}" + + var buf bytes.Buffer + PrintHTML(&buf, clones, "/repo") + got := buf.String() + for _, want := range []string{ + "", + "godedup report", + "1 groups", + "1 exact", + "class=\"clone-group funcs-2\"", + "pkg.A", + "a.go:10", + "func A() int", + "pkg.B", + "b.go:20", + "file:///repo/a.go:10", + } { + if !strings.Contains(got, want) { + t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) + } + } + for _, unwanted := range []string{"Suggestion:", "review this clone group"} { + if strings.Contains(got, unwanted) { + t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) + } + } +} + func TestPrintJSON(t *testing.T) { clones := []Clone{{ Exact: true, diff --git a/main.go b/main.go index fb5530d..6065503 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ Examples: godedup --exclude '(_test|[.]pb|[.]deepcopy)[.]go$' ./... godedup --output table ./... godedup --output json ./... | jq . + godedup --output html ./... > godedup.html Flags: ` @@ -47,7 +48,7 @@ func main() { excludePatterns = append(excludePatterns, re) return nil }) - output := flag.String("output", "text", "output format: text, table, json") + output := flag.String("output", "text", "output format: text, table, json, html") showVer := flag.Bool("version", false, "print version and exit") flag.Usage = func() { @@ -63,10 +64,10 @@ func main() { } switch *output { - case "text", "table", "json": + case "text", "table", "json", "html": // valid default: - fmtx.Fprintf(os.Stderr, "godedup: unknown output format %q (want: text, table, json)\n", *output) + fmtx.Fprintf(os.Stderr, "godedup: unknown output format %q (want: text, table, json, html)\n", *output) os.Exit(1) } @@ -109,6 +110,8 @@ func main() { report.PrintJSON(os.Stdout, clones) case "table": report.PrintTable(os.Stdout, clones, cwd) + case "html": + report.PrintHTML(os.Stdout, clones, cwd) default: report.Print(os.Stdout, clones, cwd) } From 4a3f2ac9e9888ee3b755b07d8f630c6b419f1cf7 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 20:33:46 +0500 Subject: [PATCH 02/12] fix: html layout (fit horizontally into the box) --- internal/report/report.go | 111 ++++++++++++++++++++------------- internal/report/report_test.go | 8 ++- 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/internal/report/report.go b/internal/report/report.go index cd71a56..0f96dc8 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -249,13 +249,14 @@ func PrintHTML(w io.Writer, clones []Clone, cwd string) { html { overflow-x: auto; } body { margin: 0; - min-width: 960px; + min-width: 1280px; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .page { - width: 100%; + width: max-content; + min-width: 100%; max-width: none; padding: 24px; } @@ -289,77 +290,98 @@ h1 { gap: 8px; justify-content: flex-end; } -.stat, .badge { +.stat { border: 1px solid var(--border); border-radius: 999px; background: #f6f8fa; - padding: 6px 10px; + padding: 5px 9px; font-size: 13px; white-space: nowrap; } +.badge { + border: 1px solid var(--border); + border-radius: 999px; + background: #f6f8fa; + padding: 3px 8px; + font-size: 12px; + font-weight: 700; + line-height: 1.4; + white-space: nowrap; +} .badge.exact { color: var(--blue); } .badge.near { color: var(--purple); } .clone-group { + width: max-content; + min-width: 100%; padding: 16px; margin-bottom: 16px; } .group-header { - display: flex; - justify-content: space-between; - gap: 16px; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; align-items: center; margin-bottom: 12px; } -.group-title { +.group-id { + font-weight: 700; + color: var(--muted); +} +.group-badges { display: flex; - flex-wrap: wrap; - gap: 8px; + gap: 6px; align-items: center; - font-weight: 700; + min-width: 0; } .group-meta { color: var(--muted); font-size: 13px; white-space: nowrap; + justify-self: end; } .function-row { + display: grid; + grid-template-columns: repeat(var(--func-count), minmax(640px, max-content)); gap: 14px; align-items: stretch; overflow: visible; } -.clone-group.funcs-2 .function-row { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} -.clone-group.funcs-many .function-row { - display: flex; - width: max-content; - min-width: 100%; -} -.clone-group.funcs-many .function-card { - width: clamp(420px, 32vw, 680px); - flex: 0 0 auto; -} .function-card { - min-width: 0; + min-width: 640px; border: 1px solid var(--border); border-radius: 10px; background: #fff; - overflow: hidden; + overflow: visible; } .function-card-header { - padding: 10px 12px; + min-height: 64px; + padding: 9px 12px; border-bottom: 1px solid var(--border); background: #f6f8fa; } .function-name { font-weight: 700; - overflow-wrap: anywhere; + line-height: 1.25; + white-space: nowrap; + overflow: visible; +} +.function-subhead { + display: flex; + gap: 10px; + align-items: center; + margin-top: 4px; + white-space: nowrap; } .location { - margin-top: 3px; + overflow: visible; + white-space: nowrap; font-size: 12px; } +.func-metrics { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} a { color: var(--blue); text-decoration: none; } a:hover { text-decoration: underline; } .code { @@ -370,7 +392,7 @@ a:hover { text-decoration: underline; } } .code-line { display: grid; - grid-template-columns: 54px minmax(0, 1fr); + grid-template-columns: 54px max-content; } .line-no { color: #6e7781; @@ -379,8 +401,8 @@ a:hover { text-decoration: underline; } user-select: none; } .code-text { - white-space: pre-wrap; - overflow-wrap: anywhere; + white-space: pre; + overflow-wrap: normal; padding-right: 12px; } .empty { @@ -388,11 +410,6 @@ a:hover { text-decoration: underline; } text-align: center; color: var(--muted); } -@media (max-width: 1100px) { - .clone-group.funcs-2 .function-row { - grid-template-columns: 1fr; - } -} @@ -458,15 +475,15 @@ func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { fmtx.Fprintf(w, `
-
- #%d +
#%d
+
%s %s
%d functions
-
-`, className, groupNo, kindClass, kind, sim, len(sorted)) +
+`, className, groupNo, kindClass, kind, sim, len(sorted), len(sorted)) for _, f := range sorted { writeHTMLFunctionCard(w, f, cwd) @@ -477,13 +494,19 @@ func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) + escapedName := html.EscapeString(f.Name) + escapedLoc := html.EscapeString(loc) + escapedURL := html.EscapeString(fileURL(f.File, f.Line)) fmtx.Fprintf(w, `
-
%s
-
%s · %d stmts · %d lines
+
%s
+
+ %s + %d stmts · %d lines +
-`, html.EscapeString(f.Name), html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc), f.NumStmts, f.NumLines) +`, escapedName, escapedName, escapedURL, escapedLoc, escapedLoc, f.NumStmts, f.NumLines) lines := strings.Split(f.Source, "\n") if f.Source == "" { diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 781d56c..ac7557d 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -207,6 +207,12 @@ func TestPrintHTML(t *testing.T) { "1 groups", "1 exact", "class=\"clone-group funcs-2\"", + "class=\"group-id\">#1", + "class=\"group-badges\"", + "class=\"function-name\" title=\"pkg.A\"", + "class=\"function-row\" style=\"--func-count: 2;\"", + "class=\"function-subhead\"", + "class=\"func-metrics\">3 stmts · 7 lines", "pkg.A", "a.go:10", "func A() int", @@ -218,7 +224,7 @@ func TestPrintHTML(t *testing.T) { t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) } } - for _, unwanted := range []string{"Suggestion:", "review this clone group"} { + for _, unwanted := range []string{"Suggestion:", "review this clone group", "group-title", "pre-wrap", "text-overflow: ellipsis"} { if strings.Contains(got, unwanted) { t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) } From 1b2382bc47899a959be70ccdbc3c6883378e39ba Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 20:36:58 +0500 Subject: [PATCH 03/12] fix: html layout (scrolling) --- internal/report/report.go | 334 +++++++++++++-------------------- internal/report/report_test.go | 10 +- 2 files changed, 134 insertions(+), 210 deletions(-) diff --git a/internal/report/report.go b/internal/report/report.go index 0f96dc8..5818242 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -222,219 +222,158 @@ func Print(w io.Writer, clones []Clone, cwd string) { } fmtx.Fprintln(w) } + } // PrintHTML writes a self-contained HTML report. func PrintHTML(w io.Writer, clones []Clone, cwd string) { - exact, near, funcs := cloneStats(clones) - - fmtx.Fprint(w, ` - - - - -godedup report - - - -
+.code-text { padding-right: 16px; } +.empty-msg { padding: 32px; text-align: center; color: var(--muted); } `) - - fmtx.Fprintf(w, `
-
-
-

godedup report

-

Structural duplicate detection for Go

-
-
- %d groups - %d exact - %d near - %d functions -
+ fmtx.Fprint(w, "\n\n\n") + + fmtx.Fprintf(w, `
+
godedup report
Structural duplicate detection for Go
+
+ %d groups + %d functions + %d exact + %d near
-
-`, len(clones), exact, near, funcs) +
+`, len(clones), fnCount, exact, near) if len(clones) == 0 { - fmtx.Fprint(w, `
No structural duplicates found.
`) - fmtx.Fprint(w, "\n\n\n\n") + fmtx.Fprint(w, "
No structural duplicates found.
\n") + fmtx.Fprint(w, "\n\n") return } @@ -442,7 +381,7 @@ a:hover { text-decoration: underline; } writeHTMLCloneGroup(w, i+1, clone, cwd) } - fmtx.Fprint(w, "\n\n\n") + fmtx.Fprint(w, "\n\n") } func cloneStats(clones []Clone) (exact int, near int, funcs int) { @@ -468,57 +407,49 @@ func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { } sorted := sortedFuncs(clone.Funcs) - className := "funcs-many" + groupClass := "group " + kindClass if len(sorted) == 2 { - className = "funcs-2" + groupClass += " funcs-2" } - fmtx.Fprintf(w, `
-
-
#%d
-
- %s - %s -
-
%d functions
-
-
-`, className, groupNo, kindClass, kind, sim, len(sorted), len(sorted)) + fmtx.Fprintf(w, "
\n", groupClass) + fmtx.Fprintf(w, "
\n") + fmtx.Fprintf(w, " #%d\n", groupNo) + fmtx.Fprintf(w, " %s\n", kindClass, kind) + fmtx.Fprintf(w, " %s\n", sim) + fmtx.Fprintf(w, " %d functions\n", len(sorted)) + fmtx.Fprint(w, "
\n") + fmtx.Fprint(w, "
\n") for _, f := range sorted { writeHTMLFunctionCard(w, f, cwd) } - fmtx.Fprint(w, "
\n
\n") + fmtx.Fprint(w, "
\n
\n") } func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) - escapedName := html.EscapeString(f.Name) - escapedLoc := html.EscapeString(loc) - escapedURL := html.EscapeString(fileURL(f.File, f.Line)) - fmtx.Fprintf(w, `
-
-
%s
-
- %s - %d stmts · %d lines -
-
-
-`, escapedName, escapedName, escapedURL, escapedLoc, escapedLoc, f.NumStmts, f.NumLines) + fmtx.Fprint(w, "
\n") + fmtx.Fprintf(w, "
\n") + fmtx.Fprintf(w, "
%s
\n", html.EscapeString(f.Name)) + fmtx.Fprintf(w, " \n", + html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc)) + fmtx.Fprintf(w, "
%d stmts · %d lines
\n", f.NumStmts, f.NumLines) + fmtx.Fprint(w, "
\n") + fmtx.Fprint(w, "
")
 
 	lines := strings.Split(f.Source, "\n")
 	if f.Source == "" {
-		lines = []string{"source unavailable"}
+		lines = []string{"(source unavailable)"}
 	}
 	for i, line := range lines {
 		lineNo := f.Line + i
-		fmtx.Fprintf(w, `        
%d%s
-`, html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) + fmtx.Fprintf(w, "
%d%s
", + html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) } - fmtx.Fprint(w, "
\n
\n") + fmtx.Fprint(w, "
\n
\n") } func fileURL(path string, line int) string { @@ -537,7 +468,6 @@ func sortedFuncs(funcs []hash.FuncInfo) []hash.FuncInfo { return sorted } -// PrintJSON writes machine-readable JSON output. func PrintJSON(w io.Writer, clones []Clone) { fmtx.Fprintln(w, "[") for i, clone := range clones { diff --git a/internal/report/report_test.go b/internal/report/report_test.go index ac7557d..fb32897 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -206,13 +206,7 @@ func TestPrintHTML(t *testing.T) { "godedup report", "1 groups", "1 exact", - "class=\"clone-group funcs-2\"", - "class=\"group-id\">#1", - "class=\"group-badges\"", - "class=\"function-name\" title=\"pkg.A\"", - "class=\"function-row\" style=\"--func-count: 2;\"", - "class=\"function-subhead\"", - "class=\"func-metrics\">3 stmts · 7 lines", + "class=\"group exact funcs-2\"", "pkg.A", "a.go:10", "func A() int", @@ -224,7 +218,7 @@ func TestPrintHTML(t *testing.T) { t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) } } - for _, unwanted := range []string{"Suggestion:", "review this clone group", "group-title", "pre-wrap", "text-overflow: ellipsis"} { + for _, unwanted := range []string{"Suggestion:", "review this clone group"} { if strings.Contains(got, unwanted) { t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) } From b1e143b495bfb4751a7afdc4ec4d06523f22e8ce Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 20:41:44 +0500 Subject: [PATCH 04/12] fix: html layout (stable v1) --- internal/report/report.go | 333 ++++++++++++++++++++------------- internal/report/report_test.go | 10 +- 2 files changed, 210 insertions(+), 133 deletions(-) diff --git a/internal/report/report.go b/internal/report/report.go index 5818242..a267f50 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -227,153 +227,215 @@ func Print(w io.Writer, clones []Clone, cwd string) { // PrintHTML writes a self-contained HTML report. func PrintHTML(w io.Writer, clones []Clone, cwd string) { - exact, near, fnCount := cloneStats(clones) - - fmtx.Fprint(w, "\n\n\n\n\ngodedup report\n + + +
`) - fmtx.Fprint(w, "\n\n\n") - - fmtx.Fprintf(w, `
-
godedup report
Structural duplicate detection for Go
-
- %d groups - %d functions - %d exact - %d near + + fmtx.Fprintf(w, `
+
+
+

godedup report

+

Structural duplicate detection for Go

+
+
+ %d groups + %d exact + %d near + %d functions +
-
-`, len(clones), fnCount, exact, near) + +`, len(clones), exact, near, funcs) if len(clones) == 0 { - fmtx.Fprint(w, "
No structural duplicates found.
\n") - fmtx.Fprint(w, "\n\n") + fmtx.Fprint(w, `
No structural duplicates found.
`) + fmtx.Fprint(w, "\n
\n\n\n") return } @@ -381,7 +443,7 @@ pre { writeHTMLCloneGroup(w, i+1, clone, cwd) } - fmtx.Fprint(w, "\n\n") + fmtx.Fprint(w, "\n\n\n") } func cloneStats(clones []Clone) (exact int, near int, funcs int) { @@ -407,49 +469,57 @@ func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { } sorted := sortedFuncs(clone.Funcs) - groupClass := "group " + kindClass + className := "funcs-many" if len(sorted) == 2 { - groupClass += " funcs-2" + className = "funcs-2" } - fmtx.Fprintf(w, "
\n", groupClass) - fmtx.Fprintf(w, "
\n") - fmtx.Fprintf(w, " #%d\n", groupNo) - fmtx.Fprintf(w, " %s\n", kindClass, kind) - fmtx.Fprintf(w, " %s\n", sim) - fmtx.Fprintf(w, " %d functions\n", len(sorted)) - fmtx.Fprint(w, "
\n") - fmtx.Fprint(w, "
\n") + fmtx.Fprintf(w, `
+
+
#%d
+
+ %s + %s +
+
%d functions
+
+
+`, className, groupNo, kindClass, kind, sim, len(sorted), len(sorted)) for _, f := range sorted { writeHTMLFunctionCard(w, f, cwd) } - fmtx.Fprint(w, "
\n
\n") + fmtx.Fprint(w, " \n\n") } func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) - fmtx.Fprint(w, "
\n") - fmtx.Fprintf(w, "
\n") - fmtx.Fprintf(w, "
%s
\n", html.EscapeString(f.Name)) - fmtx.Fprintf(w, " \n", - html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc)) - fmtx.Fprintf(w, "
%d stmts · %d lines
\n", f.NumStmts, f.NumLines) - fmtx.Fprint(w, "
\n") - fmtx.Fprint(w, "
")
+	escapedName := html.EscapeString(f.Name)
+	escapedLoc := html.EscapeString(loc)
+	escapedURL := html.EscapeString(fileURL(f.File, f.Line))
+	fmtx.Fprintf(w, `    
+
+
%s
+
+ %s + %d stmts · %d lines +
+
+
+`, escapedName, escapedName, escapedURL, escapedLoc, escapedLoc, f.NumStmts, f.NumLines) lines := strings.Split(f.Source, "\n") if f.Source == "" { - lines = []string{"(source unavailable)"} + lines = []string{"source unavailable"} } for i, line := range lines { lineNo := f.Line + i - fmtx.Fprintf(w, "
%d%s
", - html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) + fmtx.Fprintf(w, `
%d%s
+`, html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) } - fmtx.Fprint(w, "
\n
\n") + fmtx.Fprint(w, " \n \n") } func fileURL(path string, line int) string { @@ -468,6 +538,7 @@ func sortedFuncs(funcs []hash.FuncInfo) []hash.FuncInfo { return sorted } +// PrintJSON writes machine-readable JSON output. func PrintJSON(w io.Writer, clones []Clone) { fmtx.Fprintln(w, "[") for i, clone := range clones { diff --git a/internal/report/report_test.go b/internal/report/report_test.go index fb32897..ac7557d 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -206,7 +206,13 @@ func TestPrintHTML(t *testing.T) { "godedup report", "1 groups", "1 exact", - "class=\"group exact funcs-2\"", + "class=\"clone-group funcs-2\"", + "class=\"group-id\">#1", + "class=\"group-badges\"", + "class=\"function-name\" title=\"pkg.A\"", + "class=\"function-row\" style=\"--func-count: 2;\"", + "class=\"function-subhead\"", + "class=\"func-metrics\">3 stmts · 7 lines", "pkg.A", "a.go:10", "func A() int", @@ -218,7 +224,7 @@ func TestPrintHTML(t *testing.T) { t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) } } - for _, unwanted := range []string{"Suggestion:", "review this clone group"} { + for _, unwanted := range []string{"Suggestion:", "review this clone group", "group-title", "pre-wrap", "text-overflow: ellipsis"} { if strings.Contains(got, unwanted) { t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) } From e278788cc4e0e02439951883e616e16af71190d6 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 20:42:44 +0500 Subject: [PATCH 05/12] fix: html layout (stable v2) --- internal/report/report.go | 334 +++++++++++++-------------------- internal/report/report_test.go | 10 +- 2 files changed, 133 insertions(+), 211 deletions(-) diff --git a/internal/report/report.go b/internal/report/report.go index a267f50..76458d8 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -222,220 +222,157 @@ func Print(w io.Writer, clones []Clone, cwd string) { } fmtx.Fprintln(w) } - } // PrintHTML writes a self-contained HTML report. func PrintHTML(w io.Writer, clones []Clone, cwd string) { - exact, near, funcs := cloneStats(clones) - - fmtx.Fprint(w, ` - - - - -godedup report - - - -
+.code-text { padding-right: 16px; } +.empty-msg { padding: 32px; text-align: center; color: var(--muted); } `) - - fmtx.Fprintf(w, `
-
-
-

godedup report

-

Structural duplicate detection for Go

-
-
- %d groups - %d exact - %d near - %d functions -
+ fmtx.Fprint(w, "\n\n\n") + + fmtx.Fprintf(w, `
+
godedup report
Structural duplicate detection for Go
+
+ %d groups + %d functions + %d exact + %d near
-
-`, len(clones), exact, near, funcs) + +`, len(clones), fnCount, exact, near) if len(clones) == 0 { - fmtx.Fprint(w, `
No structural duplicates found.
`) - fmtx.Fprint(w, "\n
\n\n\n") + fmtx.Fprint(w, "
No structural duplicates found.
\n") + fmtx.Fprint(w, "\n\n") return } @@ -443,7 +380,7 @@ a:hover { text-decoration: underline; } writeHTMLCloneGroup(w, i+1, clone, cwd) } - fmtx.Fprint(w, "\n\n\n") + fmtx.Fprint(w, "\n\n") } func cloneStats(clones []Clone) (exact int, near int, funcs int) { @@ -469,57 +406,49 @@ func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { } sorted := sortedFuncs(clone.Funcs) - className := "funcs-many" + groupClass := "group " + kindClass if len(sorted) == 2 { - className = "funcs-2" + groupClass += " funcs-2" } - fmtx.Fprintf(w, `
-
-
#%d
-
- %s - %s -
-
%d functions
-
-
-`, className, groupNo, kindClass, kind, sim, len(sorted), len(sorted)) + fmtx.Fprintf(w, "
\n", groupClass) + fmtx.Fprintf(w, "
\n") + fmtx.Fprintf(w, " #%d\n", groupNo) + fmtx.Fprintf(w, " %s\n", kindClass, kind) + fmtx.Fprintf(w, " %s\n", sim) + fmtx.Fprintf(w, " %d functions\n", len(sorted)) + fmtx.Fprint(w, "
\n") + fmtx.Fprint(w, "
\n") for _, f := range sorted { writeHTMLFunctionCard(w, f, cwd) } - fmtx.Fprint(w, "
\n
\n") + fmtx.Fprint(w, "
\n
\n") } func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) - escapedName := html.EscapeString(f.Name) - escapedLoc := html.EscapeString(loc) - escapedURL := html.EscapeString(fileURL(f.File, f.Line)) - fmtx.Fprintf(w, `
-
-
%s
-
- %s - %d stmts · %d lines -
-
-
-`, escapedName, escapedName, escapedURL, escapedLoc, escapedLoc, f.NumStmts, f.NumLines) + fmtx.Fprint(w, "
\n") + fmtx.Fprintf(w, "
\n") + fmtx.Fprintf(w, "
%s
\n", html.EscapeString(f.Name)) + fmtx.Fprintf(w, " \n", + html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc)) + fmtx.Fprintf(w, "
%d stmts · %d lines
\n", f.NumStmts, f.NumLines) + fmtx.Fprint(w, "
\n") + fmtx.Fprint(w, "
")
 
 	lines := strings.Split(f.Source, "\n")
 	if f.Source == "" {
-		lines = []string{"source unavailable"}
+		lines = []string{"(source unavailable)"}
 	}
 	for i, line := range lines {
 		lineNo := f.Line + i
-		fmtx.Fprintf(w, `        
%d%s
-`, html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) + fmtx.Fprintf(w, "
%d%s
", + html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) } - fmtx.Fprint(w, "
\n
\n") + fmtx.Fprint(w, "
\n
\n") } func fileURL(path string, line int) string { @@ -538,7 +467,6 @@ func sortedFuncs(funcs []hash.FuncInfo) []hash.FuncInfo { return sorted } -// PrintJSON writes machine-readable JSON output. func PrintJSON(w io.Writer, clones []Clone) { fmtx.Fprintln(w, "[") for i, clone := range clones { diff --git a/internal/report/report_test.go b/internal/report/report_test.go index ac7557d..fb32897 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -206,13 +206,7 @@ func TestPrintHTML(t *testing.T) { "godedup report", "1 groups", "1 exact", - "class=\"clone-group funcs-2\"", - "class=\"group-id\">#1", - "class=\"group-badges\"", - "class=\"function-name\" title=\"pkg.A\"", - "class=\"function-row\" style=\"--func-count: 2;\"", - "class=\"function-subhead\"", - "class=\"func-metrics\">3 stmts · 7 lines", + "class=\"group exact funcs-2\"", "pkg.A", "a.go:10", "func A() int", @@ -224,7 +218,7 @@ func TestPrintHTML(t *testing.T) { t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) } } - for _, unwanted := range []string{"Suggestion:", "review this clone group", "group-title", "pre-wrap", "text-overflow: ellipsis"} { + for _, unwanted := range []string{"Suggestion:", "review this clone group"} { if strings.Contains(got, unwanted) { t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) } From 40e4eef642607d4fd377ae050bd13366eba7c310 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 20:50:27 +0500 Subject: [PATCH 06/12] refactor: split multiple printers into separate files --- internal/report/html.go | 255 ++++++++++++++++++++ internal/report/html_test.go | 48 ++++ internal/report/json.go | 29 +++ internal/report/json_test.go | 45 ++++ internal/report/report.go | 426 --------------------------------- internal/report/report_test.go | 140 ----------- internal/report/table.go | 115 +++++++++ internal/report/table_test.go | 40 ++++ internal/report/text.go | 61 +++++ internal/report/text_test.go | 42 ++++ 10 files changed, 635 insertions(+), 566 deletions(-) create mode 100644 internal/report/html.go create mode 100644 internal/report/html_test.go create mode 100644 internal/report/json.go create mode 100644 internal/report/json_test.go create mode 100644 internal/report/table.go create mode 100644 internal/report/table_test.go create mode 100644 internal/report/text.go create mode 100644 internal/report/text_test.go diff --git a/internal/report/html.go b/internal/report/html.go new file mode 100644 index 0000000..0b3b49c --- /dev/null +++ b/internal/report/html.go @@ -0,0 +1,255 @@ +package report + +import ( + "fmt" + "html" + "io" + "sort" + "strings" + + "github.com/hashmap-kz/godedup/internal/hash" + "github.com/hashmap-kz/godedup/internal/x/fmtx" +) + +// PrintHTML writes a self-contained HTML report. +func PrintHTML(w io.Writer, clones []Clone, cwd string) { + exact, near, fnCount := cloneStats(clones) + + fmtx.Fprint(w, "\n\n\n\n\ngodedup report\n\n\n\n") + + fmtx.Fprintf(w, `
+
godedup report
Structural duplicate detection for Go
+
+ %d groups + %d functions + %d exact + %d near +
+
+`, len(clones), fnCount, exact, near) + + if len(clones) == 0 { + fmtx.Fprint(w, "
No structural duplicates found.
\n") + fmtx.Fprint(w, "\n\n") + return + } + + for i, clone := range clones { + writeHTMLCloneGroup(w, i+1, clone, cwd) + } + + fmtx.Fprint(w, "\n\n") +} + +func cloneStats(clones []Clone) (exact, near, funcs int) { + for _, c := range clones { + funcs += len(c.Funcs) + if c.Exact { + exact++ + } else { + near++ + } + } + return exact, near, funcs +} + +func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { + kind := "EXACT" + sim := "100%" + kindClass := "exact" + if !clone.Exact { + kind = "NEAR" + sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) + kindClass = "near" + } + + sorted := sortedFuncs(clone.Funcs) + groupClass := "group " + kindClass + if len(sorted) == 2 { + groupClass += " funcs-2" + } + + fmtx.Fprintf(w, "
\n", groupClass) + fmtx.Fprintf(w, "
\n") + fmtx.Fprintf(w, " #%d\n", groupNo) + fmtx.Fprintf(w, " %s\n", kindClass, kind) + fmtx.Fprintf(w, " %s\n", sim) + fmtx.Fprintf(w, " %d functions\n", len(sorted)) + fmtx.Fprint(w, "
\n") + fmtx.Fprint(w, "
\n") + + for _, f := range sorted { + writeHTMLFunctionCard(w, f, cwd) + } + + fmtx.Fprint(w, "
\n
\n") +} + +func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { + loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) + fmtx.Fprint(w, "
\n") + fmtx.Fprintf(w, "
\n") + fmtx.Fprintf(w, "
%s
\n", html.EscapeString(f.Name)) + fmtx.Fprintf(w, " \n", + html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc)) + fmtx.Fprintf(w, "
%d stmts · %d lines
\n", f.NumStmts, f.NumLines) + fmtx.Fprint(w, "
\n") + fmtx.Fprint(w, "
")
+
+	lines := strings.Split(f.Source, "\n")
+	if f.Source == "" {
+		lines = []string{"(source unavailable)"}
+	}
+	for i, line := range lines {
+		lineNo := f.Line + i
+		fmtx.Fprintf(w, "
%d%s
", + html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) + } + + fmtx.Fprint(w, "
\n
\n") +} + +func fileURL(path string, line int) string { + return fmt.Sprintf("file://%s:%d", path, line) +} + +func sortedFuncs(funcs []hash.FuncInfo) []hash.FuncInfo { + sorted := make([]hash.FuncInfo, len(funcs)) + copy(sorted, funcs) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].File != sorted[b].File { + return sorted[a].File < sorted[b].File + } + return sorted[a].Line < sorted[b].Line + }) + return sorted +} diff --git a/internal/report/html_test.go b/internal/report/html_test.go new file mode 100644 index 0000000..bf83447 --- /dev/null +++ b/internal/report/html_test.go @@ -0,0 +1,48 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/hashmap-kz/godedup/internal/hash" +) + +func TestPrintHTML(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.B", "/repo/b.go", 20, 3, 7, 100, 1, 2, 3), + funcInfo("pkg.A", "/repo/a.go", 10, 3, 7, 100, 1, 2, 3), + }, + }} + clones[0].Funcs[0].Source = "func B() int {\n\tx := 1\n\ty := 2\n\treturn x + y\n}" + clones[0].Funcs[1].Source = "func A() int {\n\tx := 1\n\ty := 2\n\treturn x + y\n}" + + var buf bytes.Buffer + PrintHTML(&buf, clones, "/repo") + got := buf.String() + for _, want := range []string{ + "", + "godedup report", + "1 groups", + "1 exact", + "class=\"group exact funcs-2\"", + "pkg.A", + "a.go:10", + "func A() int", + "pkg.B", + "b.go:20", + "file:///repo/a.go:10", + } { + if !strings.Contains(got, want) { + t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) + } + } + for _, unwanted := range []string{"Suggestion:", "review this clone group"} { + if strings.Contains(got, unwanted) { + t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) + } + } +} diff --git a/internal/report/json.go b/internal/report/json.go new file mode 100644 index 0000000..9986765 --- /dev/null +++ b/internal/report/json.go @@ -0,0 +1,29 @@ +package report + +import ( + "io" + + "github.com/hashmap-kz/godedup/internal/x/fmtx" +) + +func PrintJSON(w io.Writer, clones []Clone) { + fmtx.Fprintln(w, "[") + for i, clone := range clones { + fmtx.Fprintf(w, ` {"exact":%v,"similarity":%.2f,"functions":[`, + clone.Exact, clone.Similarity) + for j, f := range clone.Funcs { + if j > 0 { + fmtx.Fprint(w, ",") + } + fmtx.Fprintf(w, `{"name":%q,"file":%q,"line":%d,"stmts":%d}`, + f.Name, f.File, f.Line, f.NumStmts) + } + fmtx.Fprint(w, "]}") + if i < len(clones)-1 { + fmtx.Fprintln(w, ",") + } else { + fmtx.Fprintln(w) + } + } + fmtx.Fprintln(w, "]") +} diff --git a/internal/report/json_test.go b/internal/report/json_test.go new file mode 100644 index 0000000..ebe9a86 --- /dev/null +++ b/internal/report/json_test.go @@ -0,0 +1,45 @@ +package report + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/hashmap-kz/godedup/internal/hash" +) + +func TestPrintJSON(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 3, 7, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + PrintJSON(&buf, clones) + + var decoded []struct { + Exact bool `json:"exact"` + Similarity float64 `json:"similarity"` + Functions []struct { + Name string `json:"name"` + File string `json:"file"` + Line int `json:"line"` + Stmts int `json:"stmts"` + } `json:"functions"` + } + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("PrintJSON produced invalid JSON: %v\n%s", err, buf.String()) + } + if len(decoded) != 1 { + t.Fatalf("len(decoded) = %d, want 1", len(decoded)) + } + if !decoded[0].Exact || decoded[0].Similarity != 1.0 { + t.Fatalf("decoded clone = %+v, want exact similarity 1.0", decoded[0]) + } + if got := decoded[0].Functions[0].Name; got != "pkg.A" { + t.Fatalf("function name = %q, want pkg.A", got) + } +} diff --git a/internal/report/report.go b/internal/report/report.go index 76458d8..0c6ec4e 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -1,14 +1,9 @@ package report import ( - "fmt" - "html" - "io" "sort" "strings" - "github.com/hashmap-kz/godedup/internal/x/fmtx" - "github.com/hashmap-kz/godedup/internal/hash" ) @@ -173,322 +168,6 @@ func sortClones(clones []Clone) { }) } -// Print writes a human-readable report to w. -func Print(w io.Writer, clones []Clone, cwd string) { - if len(clones) == 0 { - fmtx.Fprintln(w, "godedup: no structural duplicates found") - return - } - - exact := 0 - near := 0 - for _, c := range clones { - if c.Exact { - exact++ - } else { - near++ - } - } - - fmtx.Fprintf(w, "godedup: found %d clone group(s) (%d exact, %d near)\n\n", - len(clones), exact, near) - - for i, clone := range clones { - kind := "EXACT" - simStr := "100%" - if !clone.Exact { - kind = "NEAR" - simStr = fmt.Sprintf("%.0f%%", clone.Similarity*100) - } - - fmtx.Fprintf(w, "=== clone group %d [%s %s similarity] ===\n", - i+1, kind, simStr) - - // sort functions by file+line for stable output - sorted := make([]hash.FuncInfo, len(clone.Funcs)) - copy(sorted, clone.Funcs) - sort.Slice(sorted, func(a, b int) bool { - if sorted[a].File != sorted[b].File { - return sorted[a].File < sorted[b].File - } - return sorted[a].Line < sorted[b].Line - }) - - for _, f := range sorted { - relPath := relativePath(f.File, cwd) - fmtx.Fprintf(w, " %s\n", f.Name) - fmtx.Fprintf(w, " %s:%d (%d stmts, %d lines)\n", - relPath, f.Line, f.NumStmts, f.NumLines) - } - fmtx.Fprintln(w) - } -} - -// PrintHTML writes a self-contained HTML report. -func PrintHTML(w io.Writer, clones []Clone, cwd string) { - exact, near, fnCount := cloneStats(clones) - - fmtx.Fprint(w, "\n\n\n\n\ngodedup report\n\n\n\n") - - fmtx.Fprintf(w, `
-
godedup report
Structural duplicate detection for Go
-
- %d groups - %d functions - %d exact - %d near -
-
-`, len(clones), fnCount, exact, near) - - if len(clones) == 0 { - fmtx.Fprint(w, "
No structural duplicates found.
\n") - fmtx.Fprint(w, "\n\n") - return - } - - for i, clone := range clones { - writeHTMLCloneGroup(w, i+1, clone, cwd) - } - - fmtx.Fprint(w, "\n\n") -} - -func cloneStats(clones []Clone) (exact int, near int, funcs int) { - for _, c := range clones { - funcs += len(c.Funcs) - if c.Exact { - exact++ - } else { - near++ - } - } - return exact, near, funcs -} - -func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { - kind := "EXACT" - sim := "100%" - kindClass := "exact" - if !clone.Exact { - kind = "NEAR" - sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) - kindClass = "near" - } - - sorted := sortedFuncs(clone.Funcs) - groupClass := "group " + kindClass - if len(sorted) == 2 { - groupClass += " funcs-2" - } - - fmtx.Fprintf(w, "
\n", groupClass) - fmtx.Fprintf(w, "
\n") - fmtx.Fprintf(w, " #%d\n", groupNo) - fmtx.Fprintf(w, " %s\n", kindClass, kind) - fmtx.Fprintf(w, " %s\n", sim) - fmtx.Fprintf(w, " %d functions\n", len(sorted)) - fmtx.Fprint(w, "
\n") - fmtx.Fprint(w, "
\n") - - for _, f := range sorted { - writeHTMLFunctionCard(w, f, cwd) - } - - fmtx.Fprint(w, "
\n
\n") -} - -func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { - loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) - fmtx.Fprint(w, "
\n") - fmtx.Fprintf(w, "
\n") - fmtx.Fprintf(w, "
%s
\n", html.EscapeString(f.Name)) - fmtx.Fprintf(w, " \n", - html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc)) - fmtx.Fprintf(w, "
%d stmts · %d lines
\n", f.NumStmts, f.NumLines) - fmtx.Fprint(w, "
\n") - fmtx.Fprint(w, "
")
-
-	lines := strings.Split(f.Source, "\n")
-	if f.Source == "" {
-		lines = []string{"(source unavailable)"}
-	}
-	for i, line := range lines {
-		lineNo := f.Line + i
-		fmtx.Fprintf(w, "
%d%s
", - html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) - } - - fmtx.Fprint(w, "
\n
\n") -} - -func fileURL(path string, line int) string { - return fmt.Sprintf("file://%s:%d", path, line) -} - -func sortedFuncs(funcs []hash.FuncInfo) []hash.FuncInfo { - sorted := make([]hash.FuncInfo, len(funcs)) - copy(sorted, funcs) - sort.Slice(sorted, func(a, b int) bool { - if sorted[a].File != sorted[b].File { - return sorted[a].File < sorted[b].File - } - return sorted[a].Line < sorted[b].Line - }) - return sorted -} - -func PrintJSON(w io.Writer, clones []Clone) { - fmtx.Fprintln(w, "[") - for i, clone := range clones { - fmtx.Fprintf(w, ` {"exact":%v,"similarity":%.2f,"functions":[`, - clone.Exact, clone.Similarity) - for j, f := range clone.Funcs { - if j > 0 { - fmtx.Fprint(w, ",") - } - fmtx.Fprintf(w, `{"name":%q,"file":%q,"line":%d,"stmts":%d}`, - f.Name, f.File, f.Line, f.NumStmts) - } - fmtx.Fprint(w, "]}") - if i < len(clones)-1 { - fmtx.Fprintln(w, ",") - } else { - fmtx.Fprintln(w) - } - } - fmtx.Fprintln(w, "]") -} - func relativePath(path, cwd string) string { if cwd == "" { return path @@ -499,108 +178,3 @@ func relativePath(path, cwd string) string { } return rel } - -// PrintTable writes aligned tabular output suitable for terminal viewing. -// Columns: GROUP TYPE SIM FUNCTION LOCATION STMTS LINES -func PrintTable(w io.Writer, clones []Clone, cwd string) { - if len(clones) == 0 { - fmtx.Fprintln(w, "godedup: no structural duplicates found") - return - } - - // collect all rows first so we can compute column widths - type row struct { - group string - typ string - sim string - function string - location string - stmts string - lines string - } - - var rows []row - for i, clone := range clones { - typ := "EXACT" - sim := "100%" - if !clone.Exact { - typ = "NEAR" - sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) - } - - sorted := make([]hash.FuncInfo, len(clone.Funcs)) - copy(sorted, clone.Funcs) - sort.Slice(sorted, func(a, b int) bool { - if sorted[a].File != sorted[b].File { - return sorted[a].File < sorted[b].File - } - return sorted[a].Line < sorted[b].Line - }) - - for _, f := range sorted { - loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) - rows = append(rows, row{ - group: fmt.Sprintf("%d", i+1), - typ: typ, - sim: sim, - function: f.Name, - location: loc, - stmts: fmt.Sprintf("%d", f.NumStmts), - lines: fmt.Sprintf("%d", f.NumLines), - }) - } - } - - // compute column widths - headers := row{"GROUP", "TYPE", "SIM", "FUNCTION", "LOCATION", "STMTS", "LINES"} - widths := [7]int{ - len(headers.group), - len(headers.typ), - len(headers.sim), - len(headers.function), - len(headers.location), - len(headers.stmts), - len(headers.lines), - } - for _, r := range rows { - vals := [7]string{r.group, r.typ, r.sim, r.function, r.location, r.stmts, r.lines} - for i, v := range vals { - if len(v) > widths[i] { - widths[i] = len(v) - } - } - } - - fmtRow := func(r row) string { - return fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %s", - widths[0], r.group, - widths[1], r.typ, - widths[2], r.sim, - widths[3], r.function, - widths[4], r.location, - widths[5], r.stmts, - r.lines, - ) - } - - // header - fmtx.Fprintln(w, fmtRow(headers)) - - // separator using only dashes - sep := "" - total := widths[0] + widths[1] + widths[2] + widths[3] + widths[4] + widths[5] + widths[6] + 12 - for i := 0; i < total; i++ { - sep += "-" - } - fmtx.Fprintln(w, sep) - - // rows: emit the separator between groups - prevGroup := "" - for _, r := range rows { - if prevGroup != "" && r.group != prevGroup { - fmtx.Fprintln(w, sep) - } - fmtx.Fprintln(w, fmtRow(r)) - prevGroup = r.group - } -} diff --git a/internal/report/report_test.go b/internal/report/report_test.go index fb32897..3ac4035 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -2,7 +2,6 @@ package report import ( "bytes" - "encoding/json" "strings" "testing" @@ -122,145 +121,6 @@ func TestPrintNoClones(t *testing.T) { } } -func TestPrintHumanReadable(t *testing.T) { - clones := []Clone{{ - Exact: true, - Similarity: 1.0, - Funcs: []hash.FuncInfo{ - funcInfo("pkg.B", "/repo/b.go", 20, 3, 7, 100, 1, 2, 3), - funcInfo("pkg.A", "/repo/a.go", 10, 3, 7, 100, 1, 2, 3), - }, - }} - - var buf bytes.Buffer - Print(&buf, clones, "/repo") - got := buf.String() - for _, want := range []string{ - "godedup: found 1 clone group(s) (1 exact, 0 near)", - "=== clone group 1 [EXACT 100% similarity] ===", - "pkg.A", - "a.go:10 (3 stmts, 7 lines)", - "pkg.B", - "b.go:20 (3 stmts, 7 lines)", - } { - if !strings.Contains(got, want) { - t.Fatalf("Print() missing %q in:\n%s", want, got) - } - } - if strings.Contains(got, "suggestion:") { - t.Fatalf("Print() contains superfluous suggestion:\n%s", got) - } - if strings.Index(got, "pkg.A") > strings.Index(got, "pkg.B") { - t.Fatalf("functions are not sorted by file/line:\n%s", got) - } -} - -func TestPrintTable(t *testing.T) { - clones := []Clone{{ - Exact: false, - Similarity: 0.91, - Funcs: []hash.FuncInfo{ - funcInfo("api.handleOrderCreate", "/repo/pkg/api/order.go", 51, 19, 47, 200, 1, 2, 9), - funcInfo("api.handleUserCreate", "/repo/pkg/api/user.go", 44, 18, 45, 100, 1, 2, 3), - }, - }} - - var buf bytes.Buffer - PrintTable(&buf, clones, "/repo") - got := buf.String() - for _, want := range []string{ - "GROUP", - "TYPE", - "SIM", - "FUNCTION", - "LOCATION", - "1 NEAR 91%", - "api.handleOrderCreate", - "pkg/api/order.go:51", - "api.handleUserCreate", - "pkg/api/user.go:44", - } { - if !strings.Contains(got, want) { - t.Fatalf("PrintTable() missing %q in:\n%s", want, got) - } - } -} - -func TestPrintHTML(t *testing.T) { - clones := []Clone{{ - Exact: true, - Similarity: 1.0, - Funcs: []hash.FuncInfo{ - funcInfo("pkg.B", "/repo/b.go", 20, 3, 7, 100, 1, 2, 3), - funcInfo("pkg.A", "/repo/a.go", 10, 3, 7, 100, 1, 2, 3), - }, - }} - clones[0].Funcs[0].Source = "func B() int {\n\tx := 1\n\ty := 2\n\treturn x + y\n}" - clones[0].Funcs[1].Source = "func A() int {\n\tx := 1\n\ty := 2\n\treturn x + y\n}" - - var buf bytes.Buffer - PrintHTML(&buf, clones, "/repo") - got := buf.String() - for _, want := range []string{ - "", - "godedup report", - "1 groups", - "1 exact", - "class=\"group exact funcs-2\"", - "pkg.A", - "a.go:10", - "func A() int", - "pkg.B", - "b.go:20", - "file:///repo/a.go:10", - } { - if !strings.Contains(got, want) { - t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) - } - } - for _, unwanted := range []string{"Suggestion:", "review this clone group"} { - if strings.Contains(got, unwanted) { - t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) - } - } -} - -func TestPrintJSON(t *testing.T) { - clones := []Clone{{ - Exact: true, - Similarity: 1.0, - Funcs: []hash.FuncInfo{ - funcInfo("pkg.A", "a.go", 10, 3, 7, 100, 1, 2, 3), - }, - }} - - var buf bytes.Buffer - PrintJSON(&buf, clones) - - var decoded []struct { - Exact bool `json:"exact"` - Similarity float64 `json:"similarity"` - Functions []struct { - Name string `json:"name"` - File string `json:"file"` - Line int `json:"line"` - Stmts int `json:"stmts"` - } `json:"functions"` - } - if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { - t.Fatalf("PrintJSON produced invalid JSON: %v\n%s", err, buf.String()) - } - if len(decoded) != 1 { - t.Fatalf("len(decoded) = %d, want 1", len(decoded)) - } - if !decoded[0].Exact || decoded[0].Similarity != 1.0 { - t.Fatalf("decoded clone = %+v, want exact similarity 1.0", decoded[0]) - } - if got := decoded[0].Functions[0].Name; got != "pkg.A" { - t.Fatalf("function name = %q, want pkg.A", got) - } -} - func TestRelativePath(t *testing.T) { tests := []struct { name string diff --git a/internal/report/table.go b/internal/report/table.go new file mode 100644 index 0000000..62b5d63 --- /dev/null +++ b/internal/report/table.go @@ -0,0 +1,115 @@ +package report + +import ( + "fmt" + "io" + "sort" + + "github.com/hashmap-kz/godedup/internal/hash" + "github.com/hashmap-kz/godedup/internal/x/fmtx" +) + +// PrintTable writes aligned tabular output suitable for terminal viewing. +// Columns: GROUP TYPE SIM FUNCTION LOCATION STMTS LINES +func PrintTable(w io.Writer, clones []Clone, cwd string) { + if len(clones) == 0 { + fmtx.Fprintln(w, "godedup: no structural duplicates found") + return + } + + // collect all rows first so we can compute column widths + type row struct { + group string + typ string + sim string + function string + location string + stmts string + lines string + } + + var rows []row + for i, clone := range clones { + typ := "EXACT" + sim := "100%" + if !clone.Exact { + typ = "NEAR" + sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) + } + + sorted := make([]hash.FuncInfo, len(clone.Funcs)) + copy(sorted, clone.Funcs) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].File != sorted[b].File { + return sorted[a].File < sorted[b].File + } + return sorted[a].Line < sorted[b].Line + }) + + for _, f := range sorted { + loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) + rows = append(rows, row{ + group: fmt.Sprintf("%d", i+1), + typ: typ, + sim: sim, + function: f.Name, + location: loc, + stmts: fmt.Sprintf("%d", f.NumStmts), + lines: fmt.Sprintf("%d", f.NumLines), + }) + } + } + + // compute column widths + headers := row{"GROUP", "TYPE", "SIM", "FUNCTION", "LOCATION", "STMTS", "LINES"} + widths := [7]int{ + len(headers.group), + len(headers.typ), + len(headers.sim), + len(headers.function), + len(headers.location), + len(headers.stmts), + len(headers.lines), + } + for _, r := range rows { + vals := [7]string{r.group, r.typ, r.sim, r.function, r.location, r.stmts, r.lines} + for i, v := range vals { + if len(v) > widths[i] { + widths[i] = len(v) + } + } + } + + fmtRow := func(r row) string { + return fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %s", + widths[0], r.group, + widths[1], r.typ, + widths[2], r.sim, + widths[3], r.function, + widths[4], r.location, + widths[5], r.stmts, + r.lines, + ) + } + + // header + fmtx.Fprintln(w, fmtRow(headers)) + + // separator using only dashes + sep := "" + total := widths[0] + widths[1] + widths[2] + widths[3] + widths[4] + widths[5] + widths[6] + 12 + for i := 0; i < total; i++ { + sep += "-" + } + fmtx.Fprintln(w, sep) + + // rows: emit the separator between groups + prevGroup := "" + for _, r := range rows { + if prevGroup != "" && r.group != prevGroup { + fmtx.Fprintln(w, sep) + } + fmtx.Fprintln(w, fmtRow(r)) + prevGroup = r.group + } +} diff --git a/internal/report/table_test.go b/internal/report/table_test.go new file mode 100644 index 0000000..961cced --- /dev/null +++ b/internal/report/table_test.go @@ -0,0 +1,40 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/hashmap-kz/godedup/internal/hash" +) + +func TestPrintTable(t *testing.T) { + clones := []Clone{{ + Exact: false, + Similarity: 0.91, + Funcs: []hash.FuncInfo{ + funcInfo("api.handleOrderCreate", "/repo/pkg/api/order.go", 51, 19, 47, 200, 1, 2, 9), + funcInfo("api.handleUserCreate", "/repo/pkg/api/user.go", 44, 18, 45, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + PrintTable(&buf, clones, "/repo") + got := buf.String() + for _, want := range []string{ + "GROUP", + "TYPE", + "SIM", + "FUNCTION", + "LOCATION", + "1 NEAR 91%", + "api.handleOrderCreate", + "pkg/api/order.go:51", + "api.handleUserCreate", + "pkg/api/user.go:44", + } { + if !strings.Contains(got, want) { + t.Fatalf("PrintTable() missing %q in:\n%s", want, got) + } + } +} diff --git a/internal/report/text.go b/internal/report/text.go new file mode 100644 index 0000000..26b58da --- /dev/null +++ b/internal/report/text.go @@ -0,0 +1,61 @@ +package report + +import ( + "fmt" + "io" + "sort" + + "github.com/hashmap-kz/godedup/internal/hash" + "github.com/hashmap-kz/godedup/internal/x/fmtx" +) + +// Print writes a human-readable report to w. +func Print(w io.Writer, clones []Clone, cwd string) { + if len(clones) == 0 { + fmtx.Fprintln(w, "godedup: no structural duplicates found") + return + } + + exact := 0 + near := 0 + for _, c := range clones { + if c.Exact { + exact++ + } else { + near++ + } + } + + fmtx.Fprintf(w, "godedup: found %d clone group(s) (%d exact, %d near)\n\n", + len(clones), exact, near) + + for i, clone := range clones { + kind := "EXACT" + simStr := "100%" + if !clone.Exact { + kind = "NEAR" + simStr = fmt.Sprintf("%.0f%%", clone.Similarity*100) + } + + fmtx.Fprintf(w, "=== clone group %d [%s %s similarity] ===\n", + i+1, kind, simStr) + + // sort functions by file+line for stable output + sorted := make([]hash.FuncInfo, len(clone.Funcs)) + copy(sorted, clone.Funcs) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].File != sorted[b].File { + return sorted[a].File < sorted[b].File + } + return sorted[a].Line < sorted[b].Line + }) + + for _, f := range sorted { + relPath := relativePath(f.File, cwd) + fmtx.Fprintf(w, " %s\n", f.Name) + fmtx.Fprintf(w, " %s:%d (%d stmts, %d lines)\n", + relPath, f.Line, f.NumStmts, f.NumLines) + } + fmtx.Fprintln(w) + } +} diff --git a/internal/report/text_test.go b/internal/report/text_test.go new file mode 100644 index 0000000..b323859 --- /dev/null +++ b/internal/report/text_test.go @@ -0,0 +1,42 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/hashmap-kz/godedup/internal/hash" +) + +func TestPrintHumanReadable(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.B", "/repo/b.go", 20, 3, 7, 100, 1, 2, 3), + funcInfo("pkg.A", "/repo/a.go", 10, 3, 7, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + Print(&buf, clones, "/repo") + got := buf.String() + for _, want := range []string{ + "godedup: found 1 clone group(s) (1 exact, 0 near)", + "=== clone group 1 [EXACT 100% similarity] ===", + "pkg.A", + "a.go:10 (3 stmts, 7 lines)", + "pkg.B", + "b.go:20 (3 stmts, 7 lines)", + } { + if !strings.Contains(got, want) { + t.Fatalf("Print() missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "suggestion:") { + t.Fatalf("Print() contains superfluous suggestion:\n%s", got) + } + if strings.Index(got, "pkg.A") > strings.Index(got, "pkg.B") { + t.Fatalf("functions are not sorted by file/line:\n%s", got) + } +} From ffc4b18439accee7c2b546a6540eb01360d20cd8 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 21:01:57 +0500 Subject: [PATCH 07/12] fix: html, scrolling with overflow --- internal/report/html.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/report/html.go b/internal/report/html.go index 0b3b49c..73a04f6 100644 --- a/internal/report/html.go +++ b/internal/report/html.go @@ -92,26 +92,27 @@ a:hover { text-decoration: underline; } .group-num { font-family: var(--mono); font-size: 11px; color: var(--muted); } .group-sim { font-family: var(--mono); font-size: 12px; } .group-meta { margin-left: auto; font-size: 12px; color: var(--muted); } -.fn-row-wrap { overflow-x: auto; } +.fn-row-wrap { + overflow-x: auto; +} .fn-row { display: flex; - min-width: 100%; - width: max-content; align-items: stretch; + width: max-content; + min-width: 100%; } -.group.funcs-2 .fn-row-wrap { overflow-x: visible; } .group.funcs-2 .fn-row { - width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; } .fn-card { - min-width: 320px; + min-width: 360px; border-right: 1px solid var(--border); display: flex; flex-direction: column; - flex: 1 1 0; } +.group.funcs-2 .fn-card { min-width: 0; } .fn-card:last-child { border-right: none; } .fn-card-hdr { padding: 7px 12px; @@ -122,14 +123,13 @@ a:hover { text-decoration: underline; } .fn-name { font-weight: 600; font-size: 13px; word-break: break-all; } .fn-loc { font-size: 11px; color: var(--muted); margin-top: 2px; } .fn-stat { font-size: 11px; color: var(--muted); margin-top: 1px; } -.code { overflow-x: auto; flex: 1; } +.code { flex: 1; } pre { font-family: var(--mono); font-size: 12px; line-height: 1.5; padding: 8px 0; white-space: pre; - min-width: max-content; } .code-line { display: flex; } .code-line:hover { background: rgba(0,0,0,.04); } @@ -170,7 +170,7 @@ pre { fmtx.Fprint(w, "\n\n") } -func cloneStats(clones []Clone) (exact, near, funcs int) { +func cloneStats(clones []Clone) (exact int, near int, funcs int) { for _, c := range clones { funcs += len(c.Funcs) if c.Exact { From 1a465cd7cffe03888ff3acc74222b61a8630e71a Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 21:16:58 +0500 Subject: [PATCH 08/12] refactor: using html/template instead of hardcoded strings --- internal/report/html.go | 208 ++++++++++++++++++++++++----------- internal/report/html_test.go | 63 ++++++++++- 2 files changed, 206 insertions(+), 65 deletions(-) diff --git a/internal/report/html.go b/internal/report/html.go index 73a04f6..ef4ce90 100644 --- a/internal/report/html.go +++ b/internal/report/html.go @@ -2,7 +2,7 @@ package report import ( "fmt" - "html" + "html/template" "io" "sort" "strings" @@ -11,12 +11,43 @@ import ( "github.com/hashmap-kz/godedup/internal/x/fmtx" ) -// PrintHTML writes a self-contained HTML report. -func PrintHTML(w io.Writer, clones []Clone, cwd string) { - exact, near, fnCount := cloneStats(clones) +// htmlLine is one source line within a function card. +type htmlLine struct { + No int + FileURL template.URL + Text string +} + +// htmlFuncView is the template data for a single function card. +type htmlFuncView struct { + Name string + Location string + FileURL template.URL + NumStmts int + NumLines int + Lines []htmlLine +} + +// htmlGroupView is the template data for one clone group. +type htmlGroupView struct { + No int + KindClass string // "exact" or "near" + Kind string // "EXACT" or "NEAR" + Sim string + IsTwoFunc bool + Funcs []htmlFuncView +} - fmtx.Fprint(w, "\n\n\n\n\ngodedup report\n\n\n\n") +.empty-msg { padding: 32px; text-align: center; color: var(--muted); }` - fmtx.Fprintf(w, `
-
godedup report
Structural duplicate detection for Go
+const htmlTmpl = ` + + + + +godedup report + + + +
+
+
godedup report
+
Structural duplicate detection for Go
+
- %d groups - %d functions - %d exact - %d near + {{.Data.Total}} groups + {{.Data.FnCount}} functions + {{.Data.Exact}} exact + {{.Data.Near}} near
-`, len(clones), fnCount, exact, near) +{{- if not .Data.Groups}} +
No structural duplicates found.
+{{- else}} +{{- range .Data.Groups}} +
+
+ #{{.No}} + {{.Kind}} + {{.Sim}} + {{len .Funcs}} functions +
+
+ {{- range .Funcs}} +
+
+
{{.Name}}
+ +
{{.NumStmts}} stmts · {{.NumLines}} lines
+
+
{{- range .Lines}}
{{.No}}{{.Text}}
{{end}}
+
+ {{- end}} +
+
+{{- end}} +{{- end}} + +` - if len(clones) == 0 { - fmtx.Fprint(w, "
No structural duplicates found.
\n") - fmtx.Fprint(w, "\n\n") - return - } +var htmlReport = template.Must( + template.New("report"). + Funcs(template.FuncMap{ + "not": func(groups []htmlGroupView) bool { return len(groups) == 0 }, + }). + Parse(htmlTmpl), +) +// PrintHTML writes a self-contained HTML report. +func PrintHTML(w io.Writer, clones []Clone, cwd string) { + exact, near, fnCount := cloneStats(clones) + + data := htmlReportView{ + Total: len(clones), + Exact: exact, + Near: near, + FnCount: fnCount, + Groups: make([]htmlGroupView, 0, len(clones)), + } for i, clone := range clones { - writeHTMLCloneGroup(w, i+1, clone, cwd) + data.Groups = append(data.Groups, buildHTMLGroup(i+1, clone, cwd)) } - fmtx.Fprint(w, "\n\n") + err := htmlReport.Execute(w, struct { + CSS template.CSS + Data htmlReportView + }{ + CSS: htmlCSS, + Data: data, + }) + if err != nil { + fmtx.Fprintf(w, "\n", err) + } } func cloneStats(clones []Clone) (exact int, near int, funcs int) { @@ -182,7 +270,7 @@ func cloneStats(clones []Clone) (exact int, near int, funcs int) { return exact, near, funcs } -func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { +func buildHTMLGroup(no int, clone Clone, cwd string) htmlGroupView { kind := "EXACT" sim := "100%" kindClass := "exact" @@ -191,51 +279,45 @@ func writeHTMLCloneGroup(w io.Writer, groupNo int, clone Clone, cwd string) { sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) kindClass = "near" } - sorted := sortedFuncs(clone.Funcs) - groupClass := "group " + kindClass - if len(sorted) == 2 { - groupClass += " funcs-2" - } - - fmtx.Fprintf(w, "
\n", groupClass) - fmtx.Fprintf(w, "
\n") - fmtx.Fprintf(w, " #%d\n", groupNo) - fmtx.Fprintf(w, " %s\n", kindClass, kind) - fmtx.Fprintf(w, " %s\n", sim) - fmtx.Fprintf(w, " %d functions\n", len(sorted)) - fmtx.Fprint(w, "
\n") - fmtx.Fprint(w, "
\n") - + funcs := make([]htmlFuncView, 0, len(sorted)) for _, f := range sorted { - writeHTMLFunctionCard(w, f, cwd) + funcs = append(funcs, buildHTMLFunc(f, cwd)) + } + return htmlGroupView{ + No: no, + KindClass: kindClass, + Kind: kind, + Sim: sim, + IsTwoFunc: len(sorted) == 2, + Funcs: funcs, } - - fmtx.Fprint(w, "
\n
\n") } -func writeHTMLFunctionCard(w io.Writer, f hash.FuncInfo, cwd string) { +func buildHTMLFunc(f hash.FuncInfo, cwd string) htmlFuncView { loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) - fmtx.Fprint(w, "
\n") - fmtx.Fprintf(w, "
\n") - fmtx.Fprintf(w, "
%s
\n", html.EscapeString(f.Name)) - fmtx.Fprintf(w, " \n", - html.EscapeString(fileURL(f.File, f.Line)), html.EscapeString(loc)) - fmtx.Fprintf(w, "
%d stmts · %d lines
\n", f.NumStmts, f.NumLines) - fmtx.Fprint(w, "
\n") - fmtx.Fprint(w, "
")
-
-	lines := strings.Split(f.Source, "\n")
-	if f.Source == "" {
-		lines = []string{"(source unavailable)"}
+	src := f.Source
+	if src == "" {
+		src = "(source unavailable)"
 	}
-	for i, line := range lines {
+	rawLines := strings.Split(src, "\n")
+	lines := make([]htmlLine, 0, len(rawLines))
+	for i, text := range rawLines {
 		lineNo := f.Line + i
-		fmtx.Fprintf(w, "
%d%s
", - html.EscapeString(fileURL(f.File, lineNo)), lineNo, html.EscapeString(line)) + lines = append(lines, htmlLine{ + No: lineNo, + FileURL: template.URL(fileURL(f.File, lineNo)), + Text: text, + }) + } + return htmlFuncView{ + Name: f.Name, + Location: loc, + FileURL: template.URL(fileURL(f.File, f.Line)), + NumStmts: f.NumStmts, + NumLines: f.NumLines, + Lines: lines, } - - fmtx.Fprint(w, "
\n
\n") } func fileURL(path string, line int) string { diff --git a/internal/report/html_test.go b/internal/report/html_test.go index bf83447..ff638c8 100644 --- a/internal/report/html_test.go +++ b/internal/report/html_test.go @@ -23,12 +23,13 @@ func TestPrintHTML(t *testing.T) { var buf bytes.Buffer PrintHTML(&buf, clones, "/repo") got := buf.String() + for _, want := range []string{ "", "godedup report", "1 groups", "1 exact", - "class=\"group exact funcs-2\"", + `class="group exact funcs-2"`, "pkg.A", "a.go:10", "func A() int", @@ -40,9 +41,67 @@ func TestPrintHTML(t *testing.T) { t.Fatalf("PrintHTML() missing %q in:\n%s", want, got) } } - for _, unwanted := range []string{"Suggestion:", "review this clone group"} { + for _, unwanted := range []string{"Suggestion:", "review this clone group", "#ZgotmplZ"} { if strings.Contains(got, unwanted) { t.Fatalf("PrintHTML() contains unwanted %q in:\n%s", unwanted, got) } } } + +func TestPrintHTMLEmpty(t *testing.T) { + var buf bytes.Buffer + PrintHTML(&buf, nil, "/repo") + got := buf.String() + if !strings.Contains(got, "No structural duplicates found") { + t.Fatalf("PrintHTML() empty case missing expected message in:\n%s", got) + } + if strings.Contains(got, " in:\n%s", got) + } +} + +func TestPrintHTMLNearClone(t *testing.T) { + clones := []Clone{{ + Exact: false, + Similarity: 0.88, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.A", "/repo/a.go", 10, 4, 8, 100, 1, 2, 3, 4), + funcInfo("pkg.B", "/repo/b.go", 20, 4, 8, 200, 1, 2, 9, 4), + }, + }} + + var buf bytes.Buffer + PrintHTML(&buf, clones, "/repo") + got := buf.String() + + for _, want := range []string{ + `class="group near funcs-2"`, + `class="badge near"`, + "88%", + } { + if !strings.Contains(got, want) { + t.Fatalf("PrintHTML() near clone missing %q in:\n%s", want, got) + } + } +} + +func TestPrintHTMLSortsByFileLine(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.B", "/repo/b.go", 20, 3, 5, 100, 1, 2, 3), + funcInfo("pkg.A", "/repo/a.go", 10, 3, 5, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + PrintHTML(&buf, clones, "/repo") + got := buf.String() + + posA := strings.Index(got, "pkg.A") + posB := strings.Index(got, "pkg.B") + if posA > posB { + t.Fatalf("PrintHTML() functions not sorted by file/line: pkg.A at %d, pkg.B at %d", posA, posB) + } +} From fde3e68c9384a6adb0291f2fc249e62739c51e34 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 21:23:08 +0500 Subject: [PATCH 09/12] fix: lint issues, add constants --- internal/report/consts.go | 7 +++++++ internal/report/html.go | 13 ++++++++----- internal/report/table.go | 6 +++--- internal/report/text.go | 6 +++--- 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 internal/report/consts.go diff --git a/internal/report/consts.go b/internal/report/consts.go new file mode 100644 index 0000000..b36a32c --- /dev/null +++ b/internal/report/consts.go @@ -0,0 +1,7 @@ +package report + +const ( + kindExact = "EXACT" + kindNear = "NEAR" + sim100Percent = "100%" +) diff --git a/internal/report/html.go b/internal/report/html.go index ef4ce90..24a9e7b 100644 --- a/internal/report/html.go +++ b/internal/report/html.go @@ -258,7 +258,7 @@ func PrintHTML(w io.Writer, clones []Clone, cwd string) { } } -func cloneStats(clones []Clone) (exact int, near int, funcs int) { +func cloneStats(clones []Clone) (exact, near, funcs int) { for _, c := range clones { funcs += len(c.Funcs) if c.Exact { @@ -271,11 +271,11 @@ func cloneStats(clones []Clone) (exact int, near int, funcs int) { } func buildHTMLGroup(no int, clone Clone, cwd string) htmlGroupView { - kind := "EXACT" - sim := "100%" + kind := kindExact + sim := sim100Percent kindClass := "exact" if !clone.Exact { - kind = "NEAR" + kind = kindNear sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) kindClass = "near" } @@ -294,6 +294,7 @@ func buildHTMLGroup(no int, clone Clone, cwd string) htmlGroupView { } } +//nolint:gocritic func buildHTMLFunc(f hash.FuncInfo, cwd string) htmlFuncView { loc := fmt.Sprintf("%s:%d", relativePath(f.File, cwd), f.Line) src := f.Source @@ -305,7 +306,8 @@ func buildHTMLFunc(f hash.FuncInfo, cwd string) htmlFuncView { for i, text := range rawLines { lineNo := f.Line + i lines = append(lines, htmlLine{ - No: lineNo, + No: lineNo, + //nolint:gosec FileURL: template.URL(fileURL(f.File, lineNo)), Text: text, }) @@ -313,6 +315,7 @@ func buildHTMLFunc(f hash.FuncInfo, cwd string) htmlFuncView { return htmlFuncView{ Name: f.Name, Location: loc, + //nolint:gosec FileURL: template.URL(fileURL(f.File, f.Line)), NumStmts: f.NumStmts, NumLines: f.NumLines, diff --git a/internal/report/table.go b/internal/report/table.go index 62b5d63..0f2322a 100644 --- a/internal/report/table.go +++ b/internal/report/table.go @@ -30,10 +30,10 @@ func PrintTable(w io.Writer, clones []Clone, cwd string) { var rows []row for i, clone := range clones { - typ := "EXACT" - sim := "100%" + typ := kindExact + sim := sim100Percent if !clone.Exact { - typ = "NEAR" + typ = kindNear sim = fmt.Sprintf("%.0f%%", clone.Similarity*100) } diff --git a/internal/report/text.go b/internal/report/text.go index 26b58da..d1be306 100644 --- a/internal/report/text.go +++ b/internal/report/text.go @@ -30,10 +30,10 @@ func Print(w io.Writer, clones []Clone, cwd string) { len(clones), exact, near) for i, clone := range clones { - kind := "EXACT" - simStr := "100%" + kind := kindExact + simStr := sim100Percent if !clone.Exact { - kind = "NEAR" + kind = kindNear simStr = fmt.Sprintf("%.0f%%", clone.Similarity*100) } From a14e9676f560d15c029d7de5e9bc6efb104d4a63 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 21:34:06 +0500 Subject: [PATCH 10/12] docs: update readme (add html output example) --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 608e84c..6231dc8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Find structurally duplicate functions in Go code. godedup ./... ``` +**Table output** + ``` $ godedup --output=table --exclude '_test\.go$' @@ -36,6 +38,10 @@ GROUP TYPE SIM FUNCTION LOCATION ST 3 NEAR 88% worker.(*SMSJob).validate internal/worker/sms.go:48 8 21 ``` +**HTML output** + +![HTML](https://raw.githubusercontent.com/hashmap-kz/assets/main/godedup/godedup-html-v1.png) + --- ## How It Works @@ -83,6 +89,7 @@ Examples: godedup --exclude '_test\.go$' --exclude '\.pb\.go$' ./... godedup --exclude '(_test|[.]pb|[.]deepcopy)[.]go$' ./... godedup --output table ./... + godedup --output html ./... > godedup.html godedup --output json ./... | jq . Flags: @@ -90,7 +97,7 @@ Flags: --min-stmts int minimum statements to analyze (default: 3) --exact report only exact structural clones --exclude exclude files matching regexp (may be repeated) - --output string output format: text, table, json (default: text) + --output string output format: text, table, html, json (default: text) --version print version ``` From 3e7ad1dadcbf0547e02f581ed9e1c1060bf5a9e6 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 21:35:00 +0500 Subject: [PATCH 11/12] docs: update readme (change preview) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6231dc8..446b705 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Find structurally duplicate functions in Go code. godedup ./... ``` +**HTML output** + +![HTML](https://raw.githubusercontent.com/hashmap-kz/assets/main/godedup/godedup-html-v1.png) + **Table output** ``` @@ -38,10 +42,6 @@ GROUP TYPE SIM FUNCTION LOCATION ST 3 NEAR 88% worker.(*SMSJob).validate internal/worker/sms.go:48 8 21 ``` -**HTML output** - -![HTML](https://raw.githubusercontent.com/hashmap-kz/assets/main/godedup/godedup-html-v1.png) - --- ## How It Works From c774df781e6d4a151170e8fdd0fff47c2e46883f Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 4 May 2026 21:37:14 +0500 Subject: [PATCH 12/12] docs: update readme (add notes on location) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 446b705..5be7686 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ godedup ./... ![HTML](https://raw.githubusercontent.com/hashmap-kz/assets/main/godedup/godedup-html-v1.png) -**Table output** +**Table output with clickable `file.go:line` locations in supported terminals and editors** ``` $ godedup --output=table --exclude '_test\.go$'