// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// The linecount command shows the number of lines of code in a set of
// Go packages plus their dependencies. It serves as a working
// illustration of the [packages.Load] operation.
//
// Example: show gopls' total source line count, and its breakdown
// between gopls, x/tools, and the std go/* packages. (The balance
// comes from other std packages, other x/ repos, and external
// dependencies.)
//
//	$ linecount -mode=total ./gopls
//	752124
//	$ linecount -mode=total -module=golang.org/x/tools/gopls ./gopls
//	103519
//	$ linecount -mode=total -module=golang.org/x/tools ./gopls
//	99504
//	$ linecount -mode=total -prefix=go -module=std ./gopls
//	47502
//
// Example: show the top 5 modules contributing to gopls' source line count:
//
//	$ linecount -mode=module ./gopls | head -n 5
//	440274	std
//	103519	golang.org/x/tools/gopls
//	99504	golang.org/x/tools
//	40220	honnef.co/go/tools
//	17707	golang.org/x/text
//
// Example: show the top 3 largest files in the gopls module:
//
//	$ linecount -mode=file -module=golang.org/x/tools/gopls ./gopls | head -n 3
//	6841	gopls/internal/protocol/tsprotocol.go
//	3769	gopls/internal/golang/completion/completion.go
//	2202	gopls/internal/cache/snapshot.go
package main

import (
	"bytes"
	"cmp"
	"flag"
	"fmt"
	"go/scanner"
	"go/token"
	"log"
	"os"
	"path"
	"slices"
	"strings"
	"sync"

	"golang.org/x/sync/errgroup"
	"golang.org/x/tools/go/packages"
)

// TODO(adonovan): filters:
// - exclude generated files (-generated=false)
// - exclude non-CompiledGoFiles
// - include OtherFiles (asm, etc)
// - include tests (needs care to avoid double counting)

func usage() {
	// See https://go.dev/issue/63659.
	fmt.Fprintf(os.Stderr, "Usage: linecount [flags] packages...\n")
	flag.PrintDefaults()
	fmt.Fprintf(os.Stderr, `
Docs: go doc golang.org/x/tools/go/packages/internal/linecount
https://pkg.go.dev/golang.org/x/tools/go/packages/internal/linecount
`)
}

func main() {
	// Parse command line.
	log.SetPrefix("linecount: ")
	log.SetFlags(0)
	var (
		mode       = flag.String("mode", "file", "group lines by 'module', 'package', or 'file', or show only 'total'")
		prefix     = flag.String("prefix", "", "count files only in packages whose path has the specified prefix")
		onlyModule = flag.String("module", "", "count files only in the specified module")
		nonblank   = flag.Bool("nonblank", false, "count only non-comment, non-blank lines")
	)
	flag.Usage = usage
	flag.Parse()
	if len(flag.Args()) == 0 {
		usage()
		os.Exit(1)
	}

	// Load packages.
	cfg := &packages.Config{
		Mode: packages.NeedName |
			packages.NeedFiles |
			packages.NeedImports |
			packages.NeedDeps |
			packages.NeedModule,
	}
	pkgs, err := packages.Load(cfg, flag.Args()...)
	if err != nil {
		log.Fatal(err)
	}
	if packages.PrintErrors(pkgs) > 0 {
		os.Exit(1)
	}

	// Read files and count lines.
	var (
		mu        sync.Mutex
		byFile    = make(map[string]int)
		byPackage = make(map[string]int)
		byModule  = make(map[string]int)
	)
	var g errgroup.Group
	g.SetLimit(20) // file system parallelism level
	packages.Visit(pkgs, nil, func(p *packages.Package) {
		pkgpath := p.PkgPath
		module := "std"
		if p.Module != nil {
			module = p.Module.Path
		}
		if *prefix != "" && !within(pkgpath, path.Clean(*prefix)) {
			return
		}
		if *onlyModule != "" && module != *onlyModule {
			return
		}
		for _, f := range p.GoFiles {
			g.Go(func() error {
				data, err := os.ReadFile(f)
				if err != nil {
					return err
				}

				n := count(*nonblank, data)

				mu.Lock()
				byFile[f] = n
				byPackage[pkgpath] += n
				byModule[module] += n
				mu.Unlock()

				return nil
			})
		}
	})
	if err := g.Wait(); err != nil {
		log.Fatal(err)
	}

	// Display the result.
	switch *mode {
	case "file", "package", "module":
		var m map[string]int
		switch *mode {
		case "file":
			m = byFile
		case "package":
			m = byPackage
		case "module":
			m = byModule
		}
		type item struct {
			name  string
			count int
		}
		var items []item
		for name, count := range m {
			items = append(items, item{name, count})
		}
		slices.SortFunc(items, func(x, y item) int {
			return -cmp.Compare(x.count, y.count)
		})
		for _, item := range items {
			fmt.Printf("%d\t%s\n", item.count, item.name)
		}

	case "total":
		total := 0
		for _, n := range byFile {
			total += n
		}
		fmt.Printf("%d\n", total)

	default:
		log.Fatalf("invalid -mode %q (want file, package, module, or total)", *mode)
	}
}

func within(file, dir string) bool {
	return file == dir ||
		strings.HasPrefix(file, dir) && file[len(dir)] == os.PathSeparator
}

// count counts lines, or non-comment non-blank lines.
func count(nonblank bool, src []byte) int {
	if nonblank {
		// Count distinct lines containing tokens.
		var (
			fset     = token.NewFileSet()
			f        = fset.AddFile("", fset.Base(), len(src))
			prevLine = 0
			count    = 0
			scan     scanner.Scanner
		)
		scan.Init(f, src, nil, 0)
		for {
			pos, tok, _ := scan.Scan()
			if tok == token.EOF {
				break
			}
			// This may be slow because it binary
			// searches the newline offset table.
			line := f.PositionFor(pos, false).Line // ignore //line directives
			if line > prevLine {
				prevLine = line
				count++
			}
		}
		return count
	}

	// Count all lines.
	return bytes.Count(src, []byte("\n"))
}
