diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3851826..25901b1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -3,6 +3,7 @@ name: Go on: push: branches: [ "main" ] + tags: [ "v*" ] pull_request: branches: [ "main" ] @@ -18,8 +19,45 @@ jobs: with: go-version: '1.22.4' + - name: Check formatting + run: go fmt ./... + + - name: Run vet + run: go vet ./... + + - name: Verify go.mod + run: go mod tidy && git diff --exit-code + - name: Build run: go build -v ./... - name: Test - run: go test -v ./... + run: go test -v -cover ./... + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.22.4' + + - name: Build binaries + run: | + mkdir -p dist + go build -o dist/lines-linux ./cmd/lines + GOOS=darwin GOARCH=amd64 go build -o dist/lines-darwin-amd64 ./cmd/lines + GOOS=windows GOARCH=amd64 go build -o dist/lines-windows-amd64.exe ./cmd/lines + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index df8638c..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -tymon.student@gmail.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE index 5b0c3cc..888dcc8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Tymon Woźniak +Copyright (c) 2024-2026 Tymon Woźniak Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 458ac66..1317e0d 100644 --- a/README.md +++ b/README.md @@ -13,88 +13,145 @@ -Blazing-fast concurrent line counter for developers who value speed and efficiency. 🚀 -[Concurrent](https://en.wikipedia.org/wiki/Concurrent_computing) **non-blank** line counter implemented in [GO](https://go.dev/) using [lightweight execution threads](https://go.dev/tour/concurrency/1). +A concurrent, non-blank line counter for source code directories, written in GO. -## 🚀 Why Lines? +It recursively walks a directory, concurrently analyzes files, and reports the number of non-blank lines of code, grouped by file extension. -- **Speed**: Processes large directories with tens of thousands of files in milliseconds. -- **Concurrency**: Takes full advantage of modern CPUs with Go's goroutines. -- **Precision**: Counts only **non-blank** lines for accurate metrics. +The tool is designed for performance, utilizing goroutines to process files in parallel. -## ⚙️ Usage +## Installation + +To install the `lines` command-line tool, ensure you have [Go](https://go.dev/doc/install) installed and configured, then run: ```shell -lines # Prints file with the most lines at current directory -lines --dir # Path to the analysis folder -lines --top N # Prints the top N files -lines --hidden # Allow to analyze hidden files & dirs -lines --version # Prints installed version -lines --help # Prints help -lines --no-color # Disables colored standard output +go install github.com/moderrek/lines/cmd/lines@latest ``` -### 📈 Example output +This will download the source, compile it, and place the `lines` binary in your Go bin directory (`$GOPATH/bin` or `$HOME/go/in`). + +## Usage -```bat -lines --dir C:\Users\Moderr\dev --top 5 +The `lines` command accepts the following flags: + +``` +Usage: lines [options] + +Options: + -dir string + The directory to analyze (default ".") + -hidden + Include hidden files and directories in the analysis + -top uint + Show only the top N extensions by line count + -no-color + Disable colorized output + -json + Output results in JSON format + -version + Print version information and exit + -help + Show this help message and exit ``` -```out -Analyzing.. C:\Users\Moderr\dev +### Example -.java | Lines of code: 24409 -.json | Lines of code: 8828 -.yaml | Lines of code: 4980 -.tsx | Lines of code: 4357 -.yml | Lines of code: 1122 +To analyze the directory `~/projects/my-app` and display the top 5 extensions: -Time taken: 27.157ms to analyze 79 635 files +```shell +lines --dir ~/projects/my-app --top 5 ``` -## 📸 Screenshots +To get the output in JSON format, which can be piped to other tools like `jq`: -![Example Usage](/images/ss.png) +```shell +lines --dir ~/projects/my-app --json +``` -## 🖥️ Quick Start +Example output (`--json`): +```json +{ + ".css": 1122, + ".go": 15230, + ".html": 4357, + ".js": 8828, + ".mod": 4980 +} +``` -Requires +## Library Usage -- Installed [Git](https://www.git-scm.com/downloads) -- Installed [GO](https://go.dev/doc/install) +The core counting logic is available as a library. +It can be imported into other Go projects. -Steps -1. Clone repository - ```shell - git clone https://github.com/Moderrek/lines - ``` -2. Run - ```shell - go run main.go - ``` +```go +import "github.com/moderrek/lines/pkg/lines" +``` + +### Example -## © License +```go +package main -```license -MIT License +import ( + "fmt" + "log" -Copyright (c) 2024 Tymon Woźniak + "github.com/moderrek/lines/pkg/lines" +) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +func main() { + // Configure the counter with default settings. + config := lines.Config{ + IncludeHidden: false, + } + counter := lines.NewCounter(config) -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + // Run the analysis on the current directory. + result, err := counter.Run(".") + if err != nil { + log.Fatalf("Analysis failed: %v", err) + } -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + // Print results. + for ext, count := range result.LinesByExtension { + fmt.Printf("Extension: %s, Lines: %d\n", ext, count) + } +} ``` + +### Custom Configuration + +You can customize which directories and file extensions to ignore: + +```go +config := lines.Config{ + IncludeHidden: false, + IgnoredDirs: []string{"node_modules", "vendor", ".git", "target", "dist"}, + IgnoredExtensions: []string{".exe", ".dll", ".jpg", ".png"}, +} +counter := lines.NewCounter(config) +result, err := counter.Run("./src") +``` + +If `IgnoredDirs` or `IgnoredExtensions` are not provided, the library uses sensible defaults. + +## Building from Source + +1. Clone the repository: + ```shell + git clone https://github.com/Moderrek/lines.git + ``` +2. Navigate to the project directory: + ```shell + cd lines + ``` +3. Build the binary: + ```shell + go build ./cmd/lines + ``` + This will create a `lines` executable in the current directory. + +## License + +This project is licensed under the MIT License. +See the [LICENSE](LICENSE) file for details. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 17e212f..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,19 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 1.0.1 | :white_check_mark: | -| 1.0.0 | :white_check_mark: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/cmd/lines/cli.go b/cmd/lines/cli.go new file mode 100644 index 0000000..c992c3e --- /dev/null +++ b/cmd/lines/cli.go @@ -0,0 +1,39 @@ +package main + +import ( + "flag" + "io" +) + +type cliOptions struct { + dir string + version bool + help bool + hidden bool + top uint + noColor bool + color bool + json bool +} + +func parseFlags(stderr io.Writer, args []string) (*cliOptions, *flag.FlagSet, error) { + opts := &cliOptions{} + fs := flag.NewFlagSet("lines", flag.ContinueOnError) + fs.SetOutput(stderr) + + fs.StringVar(&opts.dir, "dir", ".", "The directory to analyze") + fs.BoolVar(&opts.version, "version", false, "Print the version and exit") + fs.BoolVar(&opts.help, "help", false, "Print the help message and exit") + fs.BoolVar(&opts.hidden, "hidden", false, "Allows to analize hidden files") + fs.UintVar(&opts.top, "top", 0, "Print the top N extensions") + fs.BoolVar(&opts.noColor, "no-color", false, "Disable color output") + fs.BoolVar(&opts.color, "color", false, "Force color output (e.g. when piping)") + fs.BoolVar(&opts.json, "json", false, "Output results in JSON format") + + err := fs.Parse(args[1:]) + if err != nil { + return nil, nil, err + } + + return opts, fs, nil +} diff --git a/cmd/lines/main.go b/cmd/lines/main.go new file mode 100644 index 0000000..2742951 --- /dev/null +++ b/cmd/lines/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if err := run(os.Stdout, os.Stderr, os.Args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/lines/output.go b/cmd/lines/output.go new file mode 100644 index 0000000..42a6a41 --- /dev/null +++ b/cmd/lines/output.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + + "github.com/fatih/color" + "github.com/moderrek/lines/pkg/lines" +) + +func printJSONOutput(w io.Writer, result *lines.Result) error { + jsonOutput, err := json.MarshalIndent(result.LinesByExtension, "", " ") + if err != nil { + return fmt.Errorf("error generating JSON: %w", err) + } + fmt.Fprintln(w, string(jsonOutput)) + return nil +} + +func printHumanOutput(w io.Writer, result *lines.Result, opts *cliOptions) { + lineMap := result.LinesByExtension + sortedKeys := make([]string, 0, len(lineMap)) + for key := range lineMap { + sortedKeys = append(sortedKeys, key) + } + sort.Slice(sortedKeys, func(i, j int) bool { + return lineMap[sortedKeys[i]] > lineMap[sortedKeys[j]] + }) + + extColor := color.New(color.Bold) + linesColor := color.New(color.FgWhite) + + for i, key := range sortedKeys { + if opts.top > 0 && uint(i) >= opts.top { + break + } + linesCount := lineMap[key] + if linesCount == 0 { + continue + } + + extColor.Fprintf(w, "%s", key) + fmt.Fprint(w, " ") // Separator + linesColor.Fprintf(w, "%d\n", linesCount) + } +} diff --git a/cmd/lines/run.go b/cmd/lines/run.go new file mode 100644 index 0000000..bbd9318 --- /dev/null +++ b/cmd/lines/run.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/fatih/color" + "github.com/mattn/go-isatty" + "github.com/moderrek/lines/pkg/lines" +) + +func run(stdout, stderr io.Writer, args []string) error { + opts, fs, err := parseFlags(stderr, args) + if err != nil { + return err + } + + isTerminal := isatty.IsTerminal(os.Stdout.Fd()) + useColor := (isTerminal || opts.color) && !opts.noColor + color.NoColor = !useColor + + if opts.version { + fmt.Fprintln(stdout, "Lines version 1.2.0 created by @Moderrek") + return nil + } + + if opts.help { + fmt.Fprintln(stderr, "Usage: lines [options]") + fs.PrintDefaults() + return nil + } + + startTime := time.Now() + if isTerminal && !opts.json { + fmt.Fprintf(stderr, "Analyzing.. %s\n\n", opts.dir) + } + + config := lines.Config{ + IncludeHidden: opts.hidden, + } + counter := lines.NewCounter(config) + result, err := counter.Run(opts.dir) + if err != nil { + return err + } + + if opts.json { + return printJSONOutput(stdout, result) + } + + printHumanOutput(stdout, result, opts) + + if isTerminal { + color.New(color.FgGreen).Fprintf(stderr, "\nTime taken: %v to analyze files\n", time.Since(startTime)) + } + + return nil +} diff --git a/go.mod b/go.mod index 276983b..02edd52 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,6 @@ require github.com/orcaman/concurrent-map/v2 v2.0.1 require ( github.com/fatih/color v1.17.0 github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.20 golang.org/x/sys v0.18.0 // indirect ) diff --git a/main.go b/main.go deleted file mode 100644 index 943df95..0000000 --- a/main.go +++ /dev/null @@ -1,236 +0,0 @@ -package main - -import ( - "bufio" - "flag" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - color "github.com/fatih/color" - cmap "github.com/orcaman/concurrent-map/v2" -) - -var options struct { - dir string - version bool - help bool - hidden bool - top uint - noColor bool -} - -var workers sync.WaitGroup -var lines = cmap.New[int]() - -var notAllowedExtensions = map[string]bool{ - ".exe": true, ".dll": true, ".so": true, ".dylib": true, - ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, - ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true, ".webp": true, ".svg": true, ".ico": true, - ".mp3": true, ".wav": true, ".flac": true, ".ogg": true, ".aac": true, - ".mp4": true, ".mkv": true, ".avi": true, ".mov": true, ".wmv": true, - ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, - ".icns": true, ".ttf": true, ".otf": true, ".woff": true, ".woff2": true, - ".eot": true, ".svgz": true, ".uasset": true, ".plist": true, - ".url": true, ".pbxproj": true, ".sln": true, - ".vcxproj": true, ".csproj": true, ".vcproj": true, ".tlog": true, - ".tmp": true, ".filters": true, ".idb": true, ".lock": true, ".rc": true, - ".sqlite": true, ".gdb": true, ".node": true, ".rmeta": true, - ".rlib": true, ".mcmeta": true, ".iml": true, ".map": true, ".natvis": true, - ".d": true, ".dat_old": true, ".storyboard": true, ".ilk": true, ".ppt": true, - ".pptx": true, ".odt": true, ".ods": true, ".odp": true, ".odg": true, ".mca": true, - ".psd": true, ".bin": true, ".jar": true, ".pdb": true, ".dox": true, ".db": true, - ".schem": true, ".lnk": true, ".mod": true, ".lib": true, ".o": true, ".obj": true, - ".a": true, ".class": true, ".pyc": true, ".pyo": true, ".whl": true, ".log": true, - ".in": true, "idb": true, ".dat": true, ".TAG": true, ".repositories": true, ".MF": true, -} - -var notAllowedDirs = map[string]bool{ - "node_modules": true, "vendor": true, ".git": true, "target": true, -} - -func countNonBlankLines(path string) int { - file, err := os.Open(path) - if err != nil { - c := color.New(color.FgRed) - c.Println(err) - return 0 - } - defer file.Close() - - scanner := bufio.NewScanner(file) - - buffer := make([]byte, 0, 64*1024) - scanner.Buffer(buffer, 1024*1024) - - line_counter := 0 - - for scanner.Scan() { - line := scanner.Text() - trimmed := strings.TrimSpace(line) - // Skip empty lines - if len(strings.TrimSpace(line)) > 0 { - line_counter++ - } - // Skip comments - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") || strings.HasSuffix(trimmed, "*/") || strings.HasPrefix(trimmed, "#") { - // Skip comments - _ = trimmed - continue - } - } - - if err := scanner.Err(); err != nil { - c := color.New(color.FgRed) - c.Printf("Failed to count lines %s: %s\n", path, err) - } - - return line_counter -} - -func fastLineCounter(path string) { - extension := filepath.Ext(path) - workers.Add(1) - go func() { - defer workers.Done() - countedLines := countNonBlankLines(path) - if val, ok := lines.Get(extension); ok { - lines.Set(extension, val+countedLines) - } else { - lines.Set(extension, countedLines) - } - }() -} - -func needToAnalyze(path string) bool { - // Skip hidden files - if !options.hidden && filepath.Base(path)[0] == '.' { - return false - } - extension := filepath.Ext(path) - // Skip files without extension - if len(extension) == 0 { - return false - } - // Skip not allowed extensions - if _, ok := notAllowedExtensions[extension]; ok { - return false - } - return true -} - -func walkDir(dir string) { - defer workers.Done() - - visit := func(path string, f os.FileInfo, err error) error { - if f.IsDir() && path != dir { - dirname := filepath.Base(path) - // Skip hidden directory - if !options.hidden && dirname[0] == '.' { - return filepath.SkipDir - } - // Skip node_modules, vendor, .git, target directories - if _, ok := notAllowedDirs[dirname]; ok { - return filepath.SkipDir - } - // Walk the directory - workers.Add(1) - go walkDir(path) - - return filepath.SkipDir - } - if f.Mode().IsRegular() { - if needToAnalyze(path) { - fastLineCounter(path) - } - } - return nil - } - filepath.Walk(dir, visit) -} - -func main() { - // Parse the command line flags - flag.StringVar(&options.dir, "dir", ".", "The directory to analyze") - flag.BoolVar(&options.version, "version", false, "Print the version and exit") - flag.BoolVar(&options.help, "help", false, "Print the help message and exit") - flag.BoolVar(&options.hidden, "hidden", false, "Allows to analize hidden files") - flag.UintVar(&options.top, "top", 0, "Print the top N files") - flag.BoolVar(&options.noColor, "no-color", false, "Disable color output") - - flag.Parse() - - // If the no-color flag is set, disable color output - if options.noColor { - color.NoColor = true - } - - // If the version flag is set, print the version and exit - if options.version { - c := color.New(color.FgGreen) - c.Println("Lines version 1.0.1 created by @Moderrek") - return - } - - // If the help flag is set, print the help message and exit - if options.help { - c := color.New(color.FgYellow) - c.Println("Usage: lines [options]") - flag.PrintDefaults() - return - } - - if _, err := os.Stat(options.dir); os.IsNotExist(err) { - c := color.New(color.FgRed) - c.Printf("Directory %s does not exist\n", options.dir) - return - } - - // Get current time. Will be used to calculate the time taken to analyze files - startTime := time.Now() - - c := color.New(color.FgYellow) - c.Printf("Analyzing.. %s\n\n", options.dir) - - // Start the analysis - workers.Add(1) - walkDir(options.dir) - workers.Wait() - - // Convert lines to a regular map - lineMap := make(map[string]int) - for _, key := range lines.Keys() { - lineMap[key], _ = lines.Get(key) - } - - // Sort the map by value - sortedKeys := make([]string, 0, len(lineMap)) - for key := range lineMap { - sortedKeys = append(sortedKeys, key) - } - sort.Slice(sortedKeys, func(i, j int) bool { - return lineMap[sortedKeys[i]] > lineMap[sortedKeys[j]] - }) - - // Print the top N extensions - counter := uint(0) - for _, key := range sortedKeys { - if options.top > 0 { - if counter >= options.top { - break - } - } - counter++ - var lines int = lineMap[key] - if lines == 0 { - continue - } - c := color.New(color.Bold) - c.Printf("%d. %s | Lines of code: %d\n", counter, key, lines) - } - success := color.New(color.FgGreen) - success.Printf("\nTime taken: %v to analyze files\n", time.Since(startTime)) -} diff --git a/pkg/lines/lines.go b/pkg/lines/lines.go new file mode 100644 index 0000000..44565d8 --- /dev/null +++ b/pkg/lines/lines.go @@ -0,0 +1,245 @@ +package lines + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + cmap "github.com/orcaman/concurrent-map/v2" +) + +// Config holds settings for the line counting process. +type Config struct { + // IncludeHidden analyzes hidden files and directories (starting with '.'). + IncludeHidden bool + // IgnoredDirs are directories to skip during analysis. Defaults to ["node_modules", "vendor", ".git", "target"]. + IgnoredDirs []string + // IgnoredExtensions are file extensions to skip. Defaults to common binary and media formats. + IgnoredExtensions []string + // BufferInitialSize is the initial buffer size for the scanner. Defaults to 64KB. + BufferInitialSize int + // BufferMaxSize is the maximum buffer size for the scanner. Defaults to 1MB. + BufferMaxSize int +} + +// Represents the results of the line counting process. +type Result struct { + // LinesByExtension maps file extensions to their total line counts. + LinesByExtension map[string]int +} + +// Counter analyzes directories and counts non-blank lines of code. +// NOTE: Counter is safe for concurrent use and uses goroutines internally. +type Counter struct { + config Config + lines cmap.ConcurrentMap[string, int] + workers sync.WaitGroup +} + +// NewCounter creates a new Counter with the given configuration. +// If IgnoredDirs or IgnoredExtensions are empty, sensible defaults are used. +func NewCounter(config Config) *Counter { + // Use sensible defaults if lists are empty. + if len(config.IgnoredDirs) == 0 { + config.IgnoredDirs = defaultIgnoredDirs() + } + if len(config.IgnoredExtensions) == 0 { + config.IgnoredExtensions = defaultIgnoredExtensions() + } + if config.BufferInitialSize == 0 { + config.BufferInitialSize = 64 * 1024 + } + if config.BufferMaxSize == 0 { + config.BufferMaxSize = 1024 * 1024 + } + + return &Counter{ + config: config, + lines: cmap.New[int](), + } +} + +// defaultIgnoredDirs returns default directories to ignore. +func defaultIgnoredDirs() []string { + return []string{ + "node_modules", "vendor", ".git", "target", + } +} + +// defaultIgnoredExtensions returns default file extensions to ignore. +func defaultIgnoredExtensions() []string { + return []string{ + ".exe", ".dll", ".so", ".dylib", + ".zip", ".tar", ".gz", ".bz2", ".xz", + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", + ".mp3", ".wav", ".flac", ".ogg", ".aac", + ".mp4", ".mkv", ".avi", ".mov", ".wmv", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", + ".icns", ".ttf", ".otf", ".woff", ".woff2", + ".eot", ".svgz", ".uasset", ".plist", + ".url", ".pbxproj", ".sln", + ".vcxproj", ".csproj", ".vcproj", ".tlog", + ".tmp", ".filters", ".idb", ".lock", ".rc", + ".sqlite", ".gdb", ".node", ".rmeta", + ".rlib", ".mcmeta", ".iml", ".map", ".natvis", + ".d", ".dat_old", ".storyboard", ".ilk", ".ppt", + ".pptx", ".odt", ".ods", ".odp", ".odg", ".mca", + ".psd", ".bin", ".jar", ".pdb", ".dox", ".db", + ".schem", ".lnk", ".mod", ".lib", ".o", ".obj", + ".a", ".class", ".pyc", ".pyo", ".whl", ".log", + ".in", ".dat", ".TAG", ".repositories", ".MF", + } +} + +// isIgnoredDir checks if a directory should be ignored. +func (c *Counter) isIgnoredDir(dirname string) bool { + for _, ignored := range c.config.IgnoredDirs { + if dirname == ignored { + return true + } + } + return false +} + +// isIgnoredExtension checks if a file extension should be ignored. +// The comparison is case-insensitive. +func (c *Counter) isIgnoredExtension(ext string) bool { + ext = strings.ToLower(ext) + for _, ignored := range c.config.IgnoredExtensions { + if ext == strings.ToLower(ignored) { + return true + } + } + return false +} + +// Run analyzes the given directory and returns the results. +// It recursively walks the directory tree using goroutines for performance. +func (c *Counter) Run(dir string) (*Result, error) { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil, fmt.Errorf("directory '%s' does not exist", dir) + } + + c.workers.Add(1) + go c.walkDir(dir) + c.workers.Wait() + + result := &Result{ + LinesByExtension: c.lines.Items(), + } + return result, nil +} + +// walkDir recursively walks the directory tree and counts lines in files. +// It spawns goroutines for each subdirectory to achieve parallel processing. +func (c *Counter) walkDir(dir string) { + defer c.workers.Done() + + visit := func(path string, f os.FileInfo, err error) error { + if err != nil { + // NOTE: Log access errors but continue with other directories. + fmt.Fprintf(os.Stderr, "ERROR: cannot access path %q: %v\n", path, err) + return err + } + if f.IsDir() && path != dir { + dirname := filepath.Base(path) + if !c.config.IncludeHidden && dirname[0] == '.' { + return filepath.SkipDir + } + if c.isIgnoredDir(dirname) { + return filepath.SkipDir + } + c.workers.Add(1) + go c.walkDir(path) + return filepath.SkipDir + } + if f.Mode().IsRegular() { + if c.needToAnalyze(path) { + c.fastLineCounter(path) + } + } + return nil + } + filepath.Walk(dir, visit) +} + +// needToAnalyze determines if a file should be analyzed. +// Returns false if the file is hidden (when IncludeHidden is false), +// has no extension, or has an ignored extension. +func (c *Counter) needToAnalyze(path string) bool { + if !c.config.IncludeHidden && filepath.Base(path)[0] == '.' { + return false + } + extension := filepath.Ext(path) + if len(extension) == 0 { + return false + } + if c.isIgnoredExtension(extension) { + return false + } + return true +} + +// fastLineCounter counts non-blank lines in a file and updates results. +// TODO: Consider caching results for frequently accessed files. +func (c *Counter) fastLineCounter(path string) { + extension := strings.ToLower(filepath.Ext(path)) + c.workers.Add(1) + go func() { + defer c.workers.Done() + countedLines, err := countNonBlankLines(path, c.config.BufferInitialSize, c.config.BufferMaxSize) + if err != nil { + // NOTE: Silently skip files with read/encoding issues. + fmt.Fprintf(os.Stderr, "WARNING: failed to count lines in %q: %v\n", path, err) + return + } + if countedLines > 0 { + c.lines.Upsert(extension, countedLines, func(exists bool, valueInMap int, newValue int) int { + if exists { + return valueInMap + newValue + } + return newValue + }) + } + }() +} + +// countNonBlankLines reads a file and counts non-blank, non-comment lines. +// Lines starting with '//' or '#' are treated as comments and skipped. +// bufferInitialSize specifies the initial scanner buffer size. +// bufferMaxSize specifies the maximum scanner buffer size. +func countNonBlankLines(path string, bufferInitialSize, bufferMaxSize int) (int, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + buffer := make([]byte, 0, bufferInitialSize) + scanner.Buffer(buffer, bufferMaxSize) + + lineCounter := 0 + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines. + if line == "" { + continue + } + // Skip comment lines: //, #, or --. + if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "--") { + continue + } + lineCounter++ + } + + if err := scanner.Err(); err != nil { + return 0, err + } + + return lineCounter, nil +}