processor/history_languages.go GO 374 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"encoding/csv"7	"fmt"8	"os"9	"slices"10	"strings"11	"time"1213	jsoniter "github.com/json-iterator/go"14	glanguage "golang.org/x/text/language"15	gmessage "golang.org/x/text/message"16)1718// languagesTimelineSparkCells is the fixed width of the Trend sparkline cell19// in the 79-column tabular report. The fixed-resolution per-bucket series is20// downsampled to this many cells.21const languagesTimelineSparkCells = 262223// languagesTimelineWideSparkCells is the sparkline width for --wide (109 cols).24const languagesTimelineWideSparkCells = 562526// languagesTimelineTopN caps tabular rows. CSV/JSON are uncapped.27const languagesTimelineTopN = 122829// languagesTimelineRow is the materialised per-language result.30type languagesTimelineRow struct {31	Language      string32	StartingLines int6433	CodeNow       int6434	Change        int6435	SharePercent  float6436	// Deltas is the per-bucket net code delta for this language.37	Deltas []int6438	// Trajectory is the absolute code count at the end of each bucket, i.e.39	// StartingLines + cumulative sum of Deltas. Used for the sparkline.40	Trajectory []int6441}4243// languagesTimelineEvent records one observed file change so the observer44// can bin it under the real window's Bucketing in Finalise (the engine45// doesn't expose the window until after the walk).46type languagesTimelineEvent struct {47	Language  string48	When      time.Time49	CodeDelta int6450}5152// historyLanguagesObserver collects per-commit per-language code deltas and53// the baseline snapshot, then materialises per-language trajectories in54// Finalise. Implements BaselineObserver so the engine seeds it with the55// pre-window tree before the walk.56type historyLanguagesObserver struct {57	starting    map[string]int6458	events      []languagesTimelineEvent59	bucketCount int6061	bucket Bucketing62	window HistoryWindow63	rows   []languagesTimelineRow64}6566func newHistoryLanguagesObserver(buckets int) *historyLanguagesObserver {67	if buckets <= 0 {68		buckets = 6069	}70	return &historyLanguagesObserver{71		starting:    map[string]int64{},72		bucketCount: buckets,73	}74}7576// Seed sums baseline code lines per language so each language gets an77// absolute starting line count.78func (o *historyLanguagesObserver) Seed(baseline BaselineSnapshot) {79	for _, bf := range baseline.Files {80		var code int6481		for _, lt := range bf.LineTypes {82			if lt == LINE_CODE {83				code++84			}85		}86		if code == 0 {87			continue88		}89		o.starting[bf.Language] += code90	}91}9293func (o *historyLanguagesObserver) Observe(c CommitInfo, changes []FileChange) {94	for _, fc := range changes {95		added := splitAddedCodeLines(fc.AddedRanges, fc.LineTypes)96		removed := splitRemovedCodeLines(fc.RemovedRanges, fc.RemovedLineTypes)97		delta := int64(added) - int64(removed)98		if delta == 0 {99			continue100		}101		o.events = append(o.events, languagesTimelineEvent{102			Language:  fc.Language,103			When:      c.When,104			CodeDelta: delta,105		})106	}107}108109func (o *historyLanguagesObserver) Finalise(window HistoryWindow, head HeadSnapshot) {110	o.window = window111	o.bucket = NewBucketing(window.From, window.To, o.bucketCount)112113	deltas := map[string][]int64{}114	for _, ev := range o.events {115		s := deltas[ev.Language]116		if s == nil {117			s = make([]int64, o.bucket.N)118			deltas[ev.Language] = s119		}120		idx := o.bucket.Index(ev.When)121		s[idx] += ev.CodeDelta122	}123124	// Union of languages — those with a starting count and those touched in125	// the window.126	langSet := map[string]struct{}{}127	for lang := range o.starting {128		langSet[lang] = struct{}{}129	}130	for lang := range deltas {131		langSet[lang] = struct{}{}132	}133134	rows := make([]languagesTimelineRow, 0, len(langSet))135	for lang := range langSet {136		start := o.starting[lang]137		series := deltas[lang]138		if series == nil {139			series = make([]int64, o.bucket.N)140		}141		traj := make([]int64, o.bucket.N)142		running := start143		for i, d := range series {144			running += d145			if running < 0 {146				running = 0147			}148			traj[i] = running149		}150		codeNow := start151		if len(traj) > 0 {152			codeNow = traj[len(traj)-1]153		}154		rows = append(rows, languagesTimelineRow{155			Language:      lang,156			StartingLines: start,157			CodeNow:       codeNow,158			Change:        codeNow - start,159			Deltas:        series,160			Trajectory:    traj,161		})162	}163164	var grand int64165	for _, r := range rows {166		grand += r.CodeNow167	}168	if grand > 0 {169		for i := range rows {170			rows[i].SharePercent = float64(rows[i].CodeNow) / float64(grand) * 100.0171		}172	}173174	slices.SortFunc(rows, func(a, b languagesTimelineRow) int {175		if a.CodeNow != b.CodeNow {176			if a.CodeNow < b.CodeNow {177				return 1178			}179			return -1180		}181		return strings.Compare(a.Language, b.Language)182	})183	o.rows = rows184}185186// runLanguagesTimelineReport is the dispatch entry point called from187// Process() when --timeline is set without --by-author. Opens the repo,188// walks the window with the configured bucket count, and writes the chosen189// format.190func runLanguagesTimelineReport(repoPath string) error {191	observer := newHistoryLanguagesObserver(HistoryBuckets)192	if _, err := runHistory(repoPath, observer); err != nil {193		return err194	}195	out, err := renderLanguagesTimeline(observer)196	if err != nil {197		return err198	}199	if FileOutput == "" {200		fmt.Print(out)201	} else {202		if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {203			return err204		}205		fmt.Println("results written to " + FileOutput)206	}207	return nil208}209210func renderLanguagesTimeline(o *historyLanguagesObserver) (string, error) {211	switch strings.ToLower(Format) {212	case "", "tabular", "wide":213		return renderLanguagesTimelineTabular(o), nil214	case "csv":215		return renderLanguagesTimelineCSV(o)216	case "json":217		return renderLanguagesTimelineJSON(o)218	default:219		return "", fmt.Errorf("unsupported --format %q for --timeline (supported: tabular, csv, json)", Format)220	}221}222223// Tabular column format. 20+1+26+1+11+1+8+1+10 = 79.224var tabularShortLanguagesTimelineFormatHead = "%-20s %-26s %11s %8s %10s\n"225226// Wide tabular: same columns, wider sparkline. 20+1+56+1+11+1+8+1+10 = 109.227var tabularWideLanguagesTimelineFormatHead = "%-20s %-56s %11s %8s %10s\n"228229func renderLanguagesTimelineTabular(o *historyLanguagesObserver) string {230	wide := More || strings.EqualFold(Format, "wide")231	brk := tabularBreakFor(wide)232233	var sb strings.Builder234	sb.WriteString(historyHeader("Languages", o.window, wide))235236	p := gmessage.NewPrinter(glanguage.Make(os.Getenv("LANG")))237238	format := tabularShortLanguagesTimelineFormatHead239	cells := languagesTimelineSparkCells240	if wide {241		format = tabularWideLanguagesTimelineFormatHead242		cells = languagesTimelineWideSparkCells243	}244245	_, _ = fmt.Fprintf(&sb, format, "Language", "Trend", "Code", "Share", "Change")246	sb.WriteString(brk)247248	limit := min(len(o.rows), languagesTimelineTopN)249250	for i := range limit {251		r := o.rows[i]252		langCol := unicodeAwareTrim(r.Language, 19)253		langCol = unicodeAwareRightPad(langCol, 20)254		spark := renderLanguagesTrajectorySparkline(r.Trajectory, cells)255		codeStr := formatWithCommas(p, r.CodeNow)256		shareStr := fmt.Sprintf("%6.1f%%", r.SharePercent)257		changeStr := formatCodeDelta(p, r.Change)258		_, _ = fmt.Fprintf(&sb, format, langCol, spark, codeStr, shareStr, changeStr)259	}260261	sb.WriteString(brk)262	return sb.String()263}264265// renderLanguagesTrajectorySparkline downsamples the absolute trajectory to266// a sparkline. Each line is normalised to its own min/max for shape clarity267// (the Share column carries cross-language comparison).268func renderLanguagesTrajectorySparkline(traj []int64, cells int) string {269	if len(traj) == 0 {270		if asciiOutput() {271			return strings.Repeat(".", cells)272		}273		return strings.Repeat("▁", cells)274	}275	values := make([]float64, len(traj))276	for i, v := range traj {277		values[i] = float64(v)278	}279	return renderSparkline(values, cells)280}281282func renderLanguagesTimelineCSV(o *historyLanguagesObserver) (string, error) {283	var sb strings.Builder284	sb.WriteString(formatWindowComment(o.window))285	sb.WriteByte('\n')286	_, _ = fmt.Fprintf(&sb, "# buckets: %d\n", o.bucket.N)287288	w := csv.NewWriter(&sb)289	_ = w.Write([]string{290		"Language", "BucketStart", "Code", "CodeNow", "SharePercent", "Change",291	})292293	for _, r := range o.rows {294		for i, code := range r.Trajectory {295			bucketStart := o.bucket.Start(i).UTC().Format(historyDateLayout)296			_ = w.Write([]string{297				r.Language,298				bucketStart,299				fmt.Sprintf("%d", code),300				fmt.Sprintf("%d", r.CodeNow),301				fmt.Sprintf("%.1f", r.SharePercent),302				fmt.Sprintf("%d", r.Change),303			})304		}305	}306	w.Flush()307	if err := w.Error(); err != nil {308		return "", err309	}310	return sb.String(), nil311}312313type languagesTimelineJSONBucket struct {314	BucketStart string `json:"bucketStart"`315	Code        int64  `json:"code"`316}317318type languagesTimelineJSONLang struct {319	Language     string                        `json:"language"`320	CodeNow      int64                         `json:"codeNow"`321	SharePercent float64                       `json:"sharePercent"`322	Change       int64                         `json:"change"`323	Series       []languagesTimelineJSONBucket `json:"series"`324}325326type languagesTimelineJSONWindow struct {327	Depth   int    `json:"depth"`328	Commits int    `json:"commits"`329	From    string `json:"from"`330	To      string `json:"to"`331}332333type languagesTimelineJSONDoc struct {334	Report    string                      `json:"report"`335	Window    languagesTimelineJSONWindow `json:"window"`336	Buckets   int                         `json:"buckets"`337	Languages []languagesTimelineJSONLang `json:"languages"`338}339340func renderLanguagesTimelineJSON(o *historyLanguagesObserver) (string, error) {341	doc := languagesTimelineJSONDoc{342		Report: "languages-timeline",343		Window: languagesTimelineJSONWindow{344			Depth:   o.window.Depth,345			Commits: o.window.Commits,346			From:    formatWindowDate(o.window.From),347			To:      formatWindowDate(o.window.To),348		},349		Buckets:   o.bucket.N,350		Languages: make([]languagesTimelineJSONLang, 0, len(o.rows)),351	}352	for _, r := range o.rows {353		jl := languagesTimelineJSONLang{354			Language:     r.Language,355			CodeNow:      r.CodeNow,356			SharePercent: round1(r.SharePercent),357			Change:       r.Change,358			Series:       make([]languagesTimelineJSONBucket, 0, len(r.Trajectory)),359		}360		for i, code := range r.Trajectory {361			jl.Series = append(jl.Series, languagesTimelineJSONBucket{362				BucketStart: o.bucket.Start(i).UTC().Format(historyDateLayout),363				Code:        code,364			})365		}366		doc.Languages = append(doc.Languages, jl)367	}368	b, err := jsoniter.Marshal(doc)369	if err != nil {370		return "", err371	}372	return string(b), nil373}

Code quality findings 12

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, format, "Language", "Trend", "Code", "Share", "Change")
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, format, langCol, spark, codeStr, shareStr, changeStr)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, "# buckets: %d\n", o.bucket.N)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
o.events = append(o.events, languagesTimelineEvent{
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, d := range series {
Unstructured output; use a structured logging library (e.g., slog, zap, zerolog, logrus)
info correctness fmt-println
fmt.Println("results written to " + FileOutput)
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, v := range traj {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, code := range r.Trajectory {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, code := range r.Trajectory {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
jl.Series = append(jl.Series, languagesTimelineJSONBucket{

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.