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, 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 []AuthorRow:336 if len(s) > n {337 return s[:n]338 }339 return s340 case []LangTimelineRow:341 if len(s) > n {342 return s[:n]343 }344 return s345 case []AuthorTimelineRow:346 if len(s) > n {347 return s[:n]348 }349 return s350 case []*FileJob:351 if len(s) > n {352 return s[:n]353 }354 return s355 case []LineLengthOutlier:356 if len(s) > n {357 return s[:n]358 }359 return s360 }361 return items362 },363 "sliceLen": func(items any) int {364 switch s := items.(type) {365 case []LanguageSummary:366 return len(s)367 case []HotspotRow:368 return len(s)369 case []AuthorRow:370 return len(s)371 case []LangTimelineRow:372 return len(s)373 case []AuthorTimelineRow:374 return len(s)375 case []*FileJob:376 return len(s)377 case []LineLengthOutlier:378 return len(s)379 case []LineLengthBucket:380 return len(s)381 }382 return 0383 },384 "deltaSign": func(n int64) string {385 if n > 0 {386 return "+"387 }388 return ""389 },390 "deltaColor": func(n int64) string {391 if n > 0 {392 return "var(--good)"393 }394 if n < 0 {395 return "var(--danger)"396 }397 return "var(--fg-muted)"398 },399 // filesSorted returns the file list sorted by line count descending so the400 // "Notable files" table is deterministic regardless of walker order.401 "filesSorted": func(files []*FileJob) []*FileJob {402 out := make([]*FileJob, len(files))403 copy(out, files)404 sort.Slice(out, func(i, j int) bool {405 if out[i].Lines != out[j].Lines {406 return out[i].Lines > out[j].Lines407 }408 return out[i].Location < out[j].Location409 })410 return out411 },412}413414func commaFmt(n int64) string {415 s := fmt.Sprintf("%d", n)416 if len(s) <= 3 {417 return s418 }419 var b strings.Builder420 pre := len(s) % 3421 if pre > 0 {422 b.WriteString(s[:pre])423 if len(s) > pre {424 b.WriteByte(',')425 }426 }427 for i := pre; i < len(s); i += 3 {428 b.WriteString(s[i : i+3])429 if i+3 < len(s) {430 b.WriteByte(',')431 }432 }433 return b.String()434}435436// humanBytes renders a byte count as "612K", "4.2M", etc. Uses SI units437// (1000-based) to match the existing tabular formatter default. Output is438// kept compact for table cells and the share card.439func humanBytes(n int64) string {440 const unit = 1000441 if n < unit {442 return fmt.Sprintf("%dB", n)443 }444 div, exp := int64(unit), 0445 for x := n / unit; x >= unit; x /= unit {446 div *= unit447 exp++448 }449 suffixes := []string{"K", "M", "G", "T", "P"}450 if exp >= len(suffixes) {451 exp = len(suffixes) - 1452 }453 v := float64(n) / float64(div)454 if v >= 100 {455 return fmt.Sprintf("%.0f%s", v, suffixes[exp])456 }457 if v >= 10 {458 return fmt.Sprintf("%.1f%s", v, suffixes[exp])459 }460 return fmt.Sprintf("%.1f%s", v, suffixes[exp])461}462463func langColor(name string) string {464 if c, ok := reportLangColors[name]; ok {465 return c466 }467 return "#999999"468}469470// donutArcs converts a sorted-by-code LanguageSummary slice into the geometry471// the donut SVG needs. circumference 2πr with r=1 keeps arithmetic simple —472// callers scale via the SVG circle's stroke-dasharray with the canonical r.473func donutArcs(summary []LanguageSummary) []DonutArc {474 var total int64475 for _, s := range summary {476 total += s.Code477 }478 if total == 0 {479 return nil480 }481 const circ = 100.0482 offset := 0.0483 arcs := make([]DonutArc, 0, len(summary))484 for _, s := range summary {485 frac := float64(s.Code) / float64(total)486 seg := frac * circ487 arcs = append(arcs, DonutArc{488 Color: langColor(s.Name),489 Dasharray: fmt.Sprintf("%.3f %.3f", seg, circ-seg),490 Dashoffset: -offset,491 })492 offset += seg493 }494 return arcs495}496497// sparklinePath turns a series into an SVG `d=` attribute scaled into a498// w×h box. Linear interpolation between data points. Empty input returns "".499func sparklinePath(values []int, w, h int) string {500 return sparklinePathInternal(intsTo64(values), w, h, false)501}502503func sparklineFill(values []int, w, h int) string {504 return sparklinePathInternal(intsTo64(values), w, h, true)505}506507func sparklinePath64(values []int64, w, h int) string {508 return sparklinePathInternal(values, w, h, false)509}510511func sparklineFill64(values []int64, w, h int) string {512 return sparklinePathInternal(values, w, h, true)513}514515func intsTo64(in []int) []int64 {516 out := make([]int64, len(in))517 for i, v := range in {518 out[i] = int64(v)519 }520 return out521}522523func sparklinePathInternal(values []int64, w, h int, closed bool) string {524 if len(values) == 0 || w <= 0 || h <= 0 {525 return ""526 }527 minV, maxV := values[0], values[0]528 for _, v := range values {529 if v < minV {530 minV = v531 }532 if v > maxV {533 maxV = v534 }535 }536 span := float64(maxV - minV)537 if span == 0 {538 span = 1539 }540 stepX := float64(w)541 if len(values) > 1 {542 stepX = float64(w) / float64(len(values)-1)543 }544 var b strings.Builder545 for i, v := range values {546 x := float64(i) * stepX547 // Invert Y so larger values are higher.548 y := float64(h) - (float64(v-minV)/span)*float64(h)549 if i == 0 {550 fmt.Fprintf(&b, "M%.1f,%.1f", x, y)551 } else {552 fmt.Fprintf(&b, " L%.1f,%.1f", x, y)553 }554 }555 if closed {556 // Close the path to the baseline so the area fill renders cleanly.557 fmt.Fprintf(&b, " L%.1f,%.1f L%.1f,%.1f Z", float64(w), float64(h), 0.0, float64(h))558 }559 return b.String()560}561562// bucketBars converts a slice of bucket counts into Bar geometry sized into563// a maxH-tall plot. Bars are uniformly spaced; the caller positions the SVG564// at whatever x-origin it wants by adding to Bar.X.565func bucketBars(buckets []int64, maxH int) []Bar {566 if len(buckets) == 0 || maxH <= 0 {567 return nil568 }569 var max int64570 for _, b := range buckets {571 if b > max {572 max = b573 }574 }575 if max == 0 {576 max = 1577 }578 const barW = 80579 const gap = 10580 bars := make([]Bar, len(buckets))581 for i, count := range buckets {582 h := int(math.Round(float64(count) / float64(max) * float64(maxH)))583 if h < 1 && count > 0 {584 h = 1585 }586 bars[i] = Bar{587 X: i*(barW+gap) + 10,588 W: barW,589 H: h,590 Y: maxH - h,591 Count: count,592 }593 }594 return bars595}596597// histoBars projects the line-length histogram buckets directly into Bar598// geometry. Saves the template from having to extract the Count field into599// a parallel slice. baseX shifts each bar's X origin so the caller can place600// the histogram beside an axis.601func histoBars(buckets []LineLengthBucket, plotH int) []Bar {602 counts := make([]int64, len(buckets))603 for i, b := range buckets {604 counts[i] = b.Count605 }606 bars := bucketBars(counts, plotH)607 for i := range bars {608 bars[i].Label = buckets[i].Label609 bars[i].Count = buckets[i].Count610 }611 return bars612}613614// authorActivity flattens an author's per-bucket Series into a list of code615// deltas suitable for sparklinePath64. Used by the timeline template.616func authorActivity(series []AuthorTimelineBucket) []int64 {617 out := make([]int64, len(series))618 for i, b := range series {619 out[i] = b.CodeDelta620 }621 return out622}623624// dataURLCard URL-encodes the rendered card SVG into a data: URL suitable625// for og:image. Uses a minimal escape set so the URL stays compact and626// human-readable when viewing source.627func dataURLCard(card template.HTML) string {628 s := string(card)629 // Strip leading whitespace / newlines so the resulting URL is compact.630 s = strings.TrimSpace(s)631 // QueryEscape encodes too aggressively for our purposes (e.g. spaces -> +)632 // but it's safe for og:image consumers, so we use it then patch the few633 // edge cases.634 encoded := url.QueryEscape(s)635 encoded = strings.ReplaceAll(encoded, "+", "%20")636 return "data:image/svg+xml;utf8," + encoded637}