1// SPDX-License-Identifier: MIT23package processor45import (6 "bufio"7 "bytes"8 _ "embed"9 "fmt"10 "html/template"11 "io"12 "math"13 "net/url"14 "os"15 "sort"16 "strings"17 "sync"1819 "github.com/mattn/go-isatty"20)2122//go:embed report_template.gohtml23var reportTemplateSrc string2425//go:embed report_card.gosvg26var reportCardSrc string2728var (29 reportTmplOnce sync.Once30 reportTmpl *template.Template31)3233// reportTemplate parses the embedded HTML report and share-card templates on34// first use so a normal scc invocation pays nothing for the report path. The35// returned root template has both "report" and "card" defined.36func reportTemplate() *template.Template {37 reportTmplOnce.Do(func() {38 root := template.New("report").Funcs(reportFuncs)39 reportTmpl = template.Must(root.Parse(reportTemplateSrc))40 template.Must(reportTmpl.New("card").Parse(reportCardSrc))41 })42 return reportTmpl43}4445// parseReportSkip turns the raw --report-skip value into the lower-cased46// lookup map CollectReportData consults via ReportSkipped. Empty input clears47// the map. Whitespace and case in each item are normalised. Unknown names48// emit a warning on stderr (per spec 05) and are still added to the map so49// that future template helpers can surface "you asked for X but it's not a50// real section" hints without re-parsing.51func parseReportSkip(raw string) {52 parseReportSkipTo(raw, os.Stderr)53}5455// parseReportSkipTo is the testable seam for parseReportSkip — the warning56// destination is plumbed through so unit tests can capture stderr output57// without resorting to os.Stderr redirection.58func parseReportSkipTo(raw string, warnW io.Writer) {59 ReportSkipNames = map[string]bool{}60 if strings.TrimSpace(raw) == "" {61 return62 }63 for part := range strings.SplitSeq(raw, ",") {64 name := strings.ToLower(strings.TrimSpace(part))65 if name == "" {66 continue67 }68 if !reportSkipRecognised[name] {69 _, _ = fmt.Fprintf(warnW, "warning: --report-skip: unknown section %q (recognised: cocomo, locomo, hotspots, coupling, authors, timeline, files, uloc, linelength, card)\n", name)70 }71 ReportSkipNames[name] = true72 }73}7475// runReport is the dispatcher entry point invoked from Process() when76// ReportOut is set. It first prompts the user before clobbering an77// existing default-named file (so a bare `--report` is non-destructive),78// then collects data and renders the HTML output.79func runReport(paths []string) error {80 path := "."81 if len(paths) > 0 {82 path = paths[0]83 }84 usedDefault := ReportOut == DefaultReportName85 stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())86 if err := confirmReportOverwrite(ReportOut, usedDefault, stdinIsTTY, os.Stdin, os.Stderr); err != nil {87 return err88 }89 data, err := CollectReportData(path)90 if err != nil {91 return err92 }93 return RenderReport(data, ReportOut)94}9596// confirmReportOverwrite asks the user before clobbering a default-named97// scc-report.html in the current directory. The contract is:98//99// - explicit path (`--report=foo.html`): treat the name as deliberate100// consent and overwrite silently — usedDefaultName is false.101// - bare `--report` and the file doesn't exist: proceed.102// - bare `--report`, file exists, TTY attached: prompt "Overwrite? [y/N]"103// and return nil only on an affirmative answer.104// - bare `--report`, file exists, no TTY (CI, piped stdin): refuse and105// point the user at the explicit-path form so the intent is auditable106// in scripts.107//108// The io.Reader / io.Writer seam keeps the function unit-testable without109// poking at os.Stdin / os.Stderr.110func confirmReportOverwrite(outPath string, usedDefaultName, stdinIsTTY bool, in io.Reader, out io.Writer) error {111 if !usedDefaultName {112 return nil113 }114 if _, err := os.Stat(outPath); os.IsNotExist(err) {115 return nil116 } else if err != nil {117 // Some other stat error (permission?) — let the subsequent118 // os.Create surface the underlying problem with a clearer119 // "create report file: ..." wrapper instead of a stat error120 // nobody asked for.121 return nil122 }123 if !stdinIsTTY {124 return fmt.Errorf("%s already exists; rerun with --report=%s to overwrite explicitly", outPath, outPath)125 }126 _, _ = fmt.Fprintf(out, "%s already exists. Overwrite? [y/N]: ", outPath)127 line, err := bufio.NewReader(in).ReadString('\n')128 if err != nil && line == "" {129 return fmt.Errorf("aborted: %w", err)130 }131 ans := strings.ToLower(strings.TrimSpace(line))132 if ans == "y" || ans == "yes" {133 return nil134 }135 return fmt.Errorf("aborted: %s not overwritten", outPath)136}137138// RenderReport renders the share card first (so the result can be embedded139// as og:image in the main template) and then writes the page to outPath.140func RenderReport(d ReportData, outPath string) error {141 f, err := os.Create(outPath)142 if err != nil {143 return fmt.Errorf("create report file: %w", err)144 }145 defer f.Close()146147 if err := renderReportTo(f, d); err != nil {148 return err149 }150151 fmt.Fprintf(os.Stderr, "Report written to %s\n", outPath)152 return nil153}154155// renderReportTo is the io.Writer-shaped seam used by the golden test in156// spec 06. It renders the share card (so the og:image data URL is157// available) and then writes the full HTML page in one pass. Callers that158// just want bytes can use a bytes.Buffer.159func renderReportTo(w io.Writer, d ReportData) error {160 if !ReportSkipped("card") {161 var cardBuf bytes.Buffer162 if err := reportTemplate().ExecuteTemplate(&cardBuf, "card", d); err != nil {163 return fmt.Errorf("render share card: %w", err)164 }165 d.CardSVG = template.HTML(cardBuf.String())166 }167 if err := reportTemplate().ExecuteTemplate(w, "report", d); err != nil {168 return fmt.Errorf("render report: %w", err)169 }170 return nil171}172173// reportLangColors is the GitHub-style language colour palette consulted by174// the langColor template helper. Anything not in the map renders with the175// neutral fallback. Keep it short — exotic languages can fall back safely.176var reportLangColors = map[string]string{177 "Go": "#00ADD8",178 "JavaScript": "#f1e05a",179 "TypeScript": "#3178c6",180 "Python": "#3572A5",181 "Java": "#b07219",182 "C": "#555555",183 "C++": "#f34b7d",184 "C#": "#178600",185 "Ruby": "#701516",186 "Rust": "#dea584",187 "PHP": "#4F5D95",188 "Swift": "#F05138",189 "Kotlin": "#A97BFF",190 "Shell": "#89e051",191 "Bash": "#89e051",192 "HTML": "#e34c26",193 "CSS": "#563d7c",194 "SCSS": "#c6538c",195 "Markdown": "#083fa1",196 "YAML": "#cb171e",197 "JSON": "#292929",198 "TOML": "#9c4221",199 "XML": "#0060ac",200 "SQL": "#e38c00",201 "Dockerfile": "#384d54",202 "Makefile": "#427819",203 "Perl": "#0298c3",204 "Lua": "#000080",205 "R": "#198CE7",206 "Scala": "#c22d40",207 "Haskell": "#5e5086",208 "Elixir": "#6e4a7e",209 "Erlang": "#B83998",210 "Clojure": "#db5855",211 "Vue": "#41b883",212 "Svelte": "#ff3e00",213 "Plain Text": "#999999",214 "Zig": "#ec915c",215}216217// DonutArc is the geometry for a single arc segment in a donut chart. Used218// by donutArcs and consumed via the dasharray/dashoffset SVG attributes on a219// <circle>. Spec calls for this even though the sample mockup uses a flat220// composition bar — included for future templates / share-card variants.221type DonutArc struct {222 Color string223 Dasharray string224 Dashoffset float64225}226227// Bar is one bar in a histogram. Used by bucketBars for the line-length228// chart. X is the SVG x-coordinate, W and H are width/height in user units.229type Bar struct {230 X int231 W int232 H int233 Y int234 Count int64235 Label string236}237238// reportFuncs is the template func map registered against both the page239// template and the share card. Everything in here is pure (no I/O, no240// globals beyond the colour map) so templates remain deterministic.241var reportFuncs = template.FuncMap{242 "comma": func(n int64) string {243 // Manual implementation avoids pulling text/message's MatchString244 // allocations for what is a hot template helper.245 if n < 0 {246 return "-" + commaFmt(-n)247 }248 return commaFmt(n)249 },250 "commaInt": func(n int) string {251 return commaFmt(int64(n))252 },253 "pct": func(num, denom int64) string {254 if denom == 0 {255 return "0.0%"256 }257 return fmt.Sprintf("%.1f%%", float64(num)/float64(denom)*100)258 },259 "pctFloat": func(v float64) string {260 return fmt.Sprintf("%.1f%%", v*100)261 },262 "pctRaw": func(num, denom int64) float64 {263 if denom == 0 {264 return 0265 }266 return float64(num) / float64(denom) * 100267 },268 "bytes": humanBytes,269 "langColor": langColor,270 "donutArcs": donutArcs,271 "sparklinePath": sparklinePath,272 "sparklineFill": sparklineFill,273 "sparklinePath64": sparklinePath64,274 "sparklineFill64": sparklineFill64,275 "bucketBars": bucketBars,276 "histoBars": histoBars,277 "authorActivity": authorActivity,278 "durationSeconds": func(d any) float64 {279 switch v := d.(type) {280 case float64:281 return v282 default:283 // time.Duration formatted via Seconds() method.284 if ds, ok := d.(interface{ Seconds() float64 }); ok {285 return ds.Seconds()286 }287 _ = v288 return 0289 }290 },291 "divFloat": func(a, b float64) float64 {292 if b == 0 {293 return 0294 }295 return a / b296 },297 "mulFloat": func(a, b float64) float64 { return a * b },298 "intCast": func(v float64) int64 { return int64(v) },299 "dataURLCard": dataURLCard,300 "safeHTML": func(s string) template.HTML { return template.HTML(s) },301 "skipped": func(section string) bool { return ReportSkipped(section) },302 "add": func(a, b int) int { return a + b },303 "sub": func(a, b int) int { return a - b },304 "mul": func(a, b int) int { return a * b },305 "div": func(a, b int) int {306 if b == 0 {307 return 0308 }309 return a / b310 },311 "int64": func(n int) int64 { return int64(n) },312 "fromInt64": func(n int64) int { return int(n) },313 "fmtTime": func(layout string, t any) string {314 switch v := t.(type) {315 case string:316 return v317 default:318 return fmt.Sprintf("%v", t)319 }320 },321 "firstN": func(n int, items any) any {322 // Generic slice truncation via reflection-free type switch on the323 // concrete slice types the template uses.324 switch s := items.(type) {325 case []LanguageSummary:326 if len(s) > n {327 return s[:n]328 }329 return s330 case []HotspotRow:331 if len(s) > n {332 return s[:n]333 }334 return s335 case []CouplingPairRow:336 if len(s) > n {337 return s[:n]338 }339 return s340 case []AuthorRow:341 if len(s) > n {342 return s[:n]343 }344 return s345 case []LangTimelineRow:346 if len(s) > n {347 return s[:n]348 }349 return s350 case []AuthorTimelineRow:351 if len(s) > n {352 return s[:n]353 }354 return s355 case []*FileJob:356 if len(s) > n {357 return s[:n]358 }359 return s360 case []LineLengthOutlier:361 if len(s) > n {362 return s[:n]363 }364 return s365 }366 return items367 },368 "sliceLen": func(items any) int {369 switch s := items.(type) {370 case []LanguageSummary:371 return len(s)372 case []HotspotRow:373 return len(s)374 case []CouplingPairRow:375 return len(s)376 case []AuthorRow:377 return len(s)378 case []LangTimelineRow:379 return len(s)380 case []AuthorTimelineRow:381 return len(s)382 case []*FileJob:383 return len(s)384 case []LineLengthOutlier:385 return len(s)386 case []LineLengthBucket:387 return len(s)388 }389 return 0390 },391 "deltaSign": func(n int64) string {392 if n > 0 {393 return "+"394 }395 return ""396 },397 "deltaColor": func(n int64) string {398 if n > 0 {399 return "var(--good)"400 }401 if n < 0 {402 return "var(--danger)"403 }404 return "var(--fg-muted)"405 },406 // filesSorted returns the file list sorted by line count descending so the407 // "Notable files" table is deterministic regardless of walker order.408 "filesSorted": func(files []*FileJob) []*FileJob {409 out := make([]*FileJob, len(files))410 copy(out, files)411 sort.Slice(out, func(i, j int) bool {412 if out[i].Lines != out[j].Lines {413 return out[i].Lines > out[j].Lines414 }415 return out[i].Location < out[j].Location416 })417 return out418 },419}420421func commaFmt(n int64) string {422 s := fmt.Sprintf("%d", n)423 if len(s) <= 3 {424 return s425 }426 var b strings.Builder427 pre := len(s) % 3428 if pre > 0 {429 b.WriteString(s[:pre])430 if len(s) > pre {431 b.WriteByte(',')432 }433 }434 for i := pre; i < len(s); i += 3 {435 b.WriteString(s[i : i+3])436 if i+3 < len(s) {437 b.WriteByte(',')438 }439 }440 return b.String()441}442443// humanBytes renders a byte count as "612K", "4.2M", etc. Uses SI units444// (1000-based) to match the existing tabular formatter default. Output is445// kept compact for table cells and the share card.446func humanBytes(n int64) string {447 const unit = 1000448 if n < unit {449 return fmt.Sprintf("%dB", n)450 }451 div, exp := int64(unit), 0452 for x := n / unit; x >= unit; x /= unit {453 div *= unit454 exp++455 }456 suffixes := []string{"K", "M", "G", "T", "P"}457 if exp >= len(suffixes) {458 exp = len(suffixes) - 1459 }460 v := float64(n) / float64(div)461 if v >= 100 {462 return fmt.Sprintf("%.0f%s", v, suffixes[exp])463 }464 if v >= 10 {465 return fmt.Sprintf("%.1f%s", v, suffixes[exp])466 }467 return fmt.Sprintf("%.1f%s", v, suffixes[exp])468}469470func langColor(name string) string {471 if c, ok := reportLangColors[name]; ok {472 return c473 }474 return "#999999"475}476477// donutArcs converts a sorted-by-code LanguageSummary slice into the geometry478// the donut SVG needs. circumference 2πr with r=1 keeps arithmetic simple —479// callers scale via the SVG circle's stroke-dasharray with the canonical r.480func donutArcs(summary []LanguageSummary) []DonutArc {481 var total int64482 for _, s := range summary {483 total += s.Code484 }485 if total == 0 {486 return nil487 }488 const circ = 100.0489 offset := 0.0490 arcs := make([]DonutArc, 0, len(summary))491 for _, s := range summary {492 frac := float64(s.Code) / float64(total)493 seg := frac * circ494 arcs = append(arcs, DonutArc{495 Color: langColor(s.Name),496 Dasharray: fmt.Sprintf("%.3f %.3f", seg, circ-seg),497 Dashoffset: -offset,498 })499 offset += seg500 }501 return arcs502}503504// sparklinePath turns a series into an SVG `d=` attribute scaled into a505// w×h box. Linear interpolation between data points. Empty input returns "".506func sparklinePath(values []int, w, h int) string {507 return sparklinePathInternal(intsTo64(values), w, h, false)508}509510func sparklineFill(values []int, w, h int) string {511 return sparklinePathInternal(intsTo64(values), w, h, true)512}513514func sparklinePath64(values []int64, w, h int) string {515 return sparklinePathInternal(values, w, h, false)516}517518func sparklineFill64(values []int64, w, h int) string {519 return sparklinePathInternal(values, w, h, true)520}521522func intsTo64(in []int) []int64 {523 out := make([]int64, len(in))524 for i, v := range in {525 out[i] = int64(v)526 }527 return out528}529530func sparklinePathInternal(values []int64, w, h int, closed bool) string {531 if len(values) == 0 || w <= 0 || h <= 0 {532 return ""533 }534 minV, maxV := values[0], values[0]535 for _, v := range values {536 if v < minV {537 minV = v538 }539 if v > maxV {540 maxV = v541 }542 }543 span := float64(maxV - minV)544 if span == 0 {545 span = 1546 }547 stepX := float64(w)548 if len(values) > 1 {549 stepX = float64(w) / float64(len(values)-1)550 }551 var b strings.Builder552 for i, v := range values {553 x := float64(i) * stepX554 // Invert Y so larger values are higher.555 y := float64(h) - (float64(v-minV)/span)*float64(h)556 if i == 0 {557 fmt.Fprintf(&b, "M%.1f,%.1f", x, y)558 } else {559 fmt.Fprintf(&b, " L%.1f,%.1f", x, y)560 }561 }562 if closed {563 // Close the path to the baseline so the area fill renders cleanly.564 fmt.Fprintf(&b, " L%.1f,%.1f L%.1f,%.1f Z", float64(w), float64(h), 0.0, float64(h))565 }566 return b.String()567}568569// bucketBars converts a slice of bucket counts into Bar geometry sized into570// a maxH-tall plot. Bars are uniformly spaced; the caller positions the SVG571// at whatever x-origin it wants by adding to Bar.X.572func bucketBars(buckets []int64, maxH int) []Bar {573 if len(buckets) == 0 || maxH <= 0 {574 return nil575 }576 var max int64577 for _, b := range buckets {578 if b > max {579 max = b580 }581 }582 if max == 0 {583 max = 1584 }585 const barW = 80586 const gap = 10587 bars := make([]Bar, len(buckets))588 for i, count := range buckets {589 h := int(math.Round(float64(count) / float64(max) * float64(maxH)))590 if h < 1 && count > 0 {591 h = 1592 }593 bars[i] = Bar{594 X: i*(barW+gap) + 10,595 W: barW,596 H: h,597 Y: maxH - h,598 Count: count,599 }600 }601 return bars602}603604// histoBars projects the line-length histogram buckets directly into Bar605// geometry. Saves the template from having to extract the Count field into606// a parallel slice. baseX shifts each bar's X origin so the caller can place607// the histogram beside an axis.608func histoBars(buckets []LineLengthBucket, plotH int) []Bar {609 counts := make([]int64, len(buckets))610 for i, b := range buckets {611 counts[i] = b.Count612 }613 bars := bucketBars(counts, plotH)614 for i := range bars {615 bars[i].Label = buckets[i].Label616 bars[i].Count = buckets[i].Count617 }618 return bars619}620621// authorActivity flattens an author's per-bucket Series into a list of code622// deltas suitable for sparklinePath64. Used by the timeline template.623func authorActivity(series []AuthorTimelineBucket) []int64 {624 out := make([]int64, len(series))625 for i, b := range series {626 out[i] = b.CodeDelta627 }628 return out629}630631// dataURLCard URL-encodes the rendered card SVG into a data: URL suitable632// for og:image. Uses a minimal escape set so the URL stays compact and633// human-readable when viewing source.634func dataURLCard(card template.HTML) string {635 s := string(card)636 // Strip leading whitespace / newlines so the resulting URL is compact.637 s = strings.TrimSpace(s)638 // QueryEscape encodes too aggressively for our purposes (e.g. spaces -> +)639 // but it's safe for og:image consumers, so we use it then patch the few640 // edge cases.641 encoded := url.QueryEscape(s)642 encoded = strings.ReplaceAll(encoded, "+", "%20")643 return "data:image/svg+xml;utf8," + encoded644}