Last active
July 8, 2026 20:30
-
-
Save drewlsvern/1d41174e50cb4f317e27143a5185032c to your computer and use it in GitHub Desktop.
pwsh core script to count lines of code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env pwsh | |
| <# | |
| .SYNOPSIS | |
| Counts lines of code in a directory tree, summarized by file extension / language. | |
| .DESCRIPTION | |
| Recursively scans a directory, skipping common noise directories (.git, node_modules, etc.) | |
| and binary files, then prints a table of extension, language, file count, and line count, | |
| sorted by line count descending. | |
| Performance: the file walk + line counting is done by an embedded C# type (compiled via | |
| Add-Type) using Parallel.ForEach and chunked byte-level newline counting, so it scales to | |
| repositories with millions of lines. PowerShell itself only handles argument parsing and | |
| formatting the final (small) summary table. | |
| .PARAMETER Path | |
| Directory to scan. Defaults to the current directory. | |
| .PARAMETER Exclude | |
| One or more glob patterns matched against each file's path relative to Path, using | |
| gitignore-style syntax: '*' and '?' match within a path segment, '**' matches any | |
| number of segments, a bare name/glob with no '/' matches at any depth, a leading '/' | |
| anchors the pattern to Path, and a trailing '/' excludes only directory contents. | |
| Applied after (and with the same syntax as) any .clocignore file found in Path. | |
| .PARAMETER IgnoreFile | |
| Path to a gitignore-style ignore file to apply in addition to -Exclude. Defaults to | |
| a '.clocignore' file in Path, if one exists. Supports '#' comments, blank lines, and | |
| '!' negation to re-include a path excluded by an earlier pattern. | |
| .EXAMPLE | |
| ./Count-LinesOfCode.ps1 | |
| .EXAMPLE | |
| ./Count-LinesOfCode.ps1 /path/to/repo | |
| .EXAMPLE | |
| ./Count-LinesOfCode.ps1 -Exclude '**/Generated/**/*.cs', '*.designer.cs' | |
| .EXAMPLE | |
| # .clocignore in the repo root: | |
| # Generated/ | |
| # *.designer.cs | |
| # !Important.designer.cs | |
| ./Count-LinesOfCode.ps1 /path/to/repo | |
| #> | |
| param( | |
| [Parameter(Position = 0)] | |
| [string]$Path = '.', | |
| [string[]]$Exclude = @(), | |
| [string]$IgnoreFile | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| $resolvedPath = (Resolve-Path -LiteralPath $Path).ProviderPath | |
| if (-not (Test-Path -LiteralPath $resolvedPath -PathType Container)) { | |
| throw "Path '$Path' is not a directory." | |
| } | |
| # Load gitignore-style patterns from the ignore file (if any), then append -Exclude | |
| # patterns so they're evaluated afterward (later patterns, including '!' negations, | |
| # take precedence over earlier ones — same as git). | |
| $ignoreFilePath = if ($IgnoreFile) { $IgnoreFile } else { Join-Path $resolvedPath '.clocignore' } | |
| $ignorePatterns = @() | |
| if (Test-Path -LiteralPath $ignoreFilePath -PathType Leaf) { | |
| $ignorePatterns = @( | |
| Get-Content -LiteralPath $ignoreFilePath | | |
| ForEach-Object { $_.Trim() } | | |
| Where-Object { $_ -ne '' -and -not $_.StartsWith('#') } | |
| ) | |
| } | |
| $allPatterns = @($ignorePatterns) + @($Exclude) | |
| # Directory names to prune during the walk (never descended into). | |
| $excludedDirs = [System.Collections.Generic.HashSet[string]]::new( | |
| [string[]]@( | |
| '.git', '.hg', '.svn', | |
| 'node_modules', 'bower_components', | |
| 'venv', '.venv', '__pycache__', '.mypy_cache', '.pytest_cache', '.tox', | |
| 'bin', 'obj', 'packages', | |
| 'dist', 'build', 'out', 'target', | |
| '.idea', '.vs', '.vscode', | |
| 'vendor', 'coverage', | |
| '.next', '.nuxt' | |
| ), | |
| [System.StringComparer]::OrdinalIgnoreCase | |
| ) | |
| # Extensions that are binary / not meaningful to count as "lines". | |
| $binaryExtensions = [System.Collections.Generic.HashSet[string]]::new( | |
| [string[]]@( | |
| '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.tiff', '.svgz', | |
| '.pdf', '.zip', '.tar', '.gz', '.tgz', '.bz2', '.7z', '.rar', '.xz', | |
| '.exe', '.dll', '.so', '.dylib', '.o', '.a', '.lib', '.pdb', '.class', '.jar', '.war', | |
| '.woff', '.woff2', '.ttf', '.otf', '.eot', | |
| '.mp3', '.mp4', '.wav', '.avi', '.mov', '.mkv', '.flac', | |
| '.db', '.sqlite', '.sqlite3', | |
| '.pyc', '.pyo', | |
| '.lock' | |
| ), | |
| [System.StringComparer]::OrdinalIgnoreCase | |
| ) | |
| # Extension -> human-readable language name. | |
| $languageMap = @{ | |
| '.cs' = 'C#' | |
| '.py' = 'Python' | |
| '.js' = 'JavaScript' | |
| '.mjs' = 'JavaScript' | |
| '.cjs' = 'JavaScript' | |
| '.jsx' = 'JavaScript (React)' | |
| '.ts' = 'TypeScript' | |
| '.tsx' = 'TypeScript (React)' | |
| '.java' = 'Java' | |
| '.go' = 'Go' | |
| '.rb' = 'Ruby' | |
| '.php' = 'PHP' | |
| '.c' = 'C' | |
| '.h' = 'C Header' | |
| '.cpp' = 'C++' | |
| '.cc' = 'C++' | |
| '.cxx' = 'C++' | |
| '.hpp' = 'C++ Header' | |
| '.hh' = 'C++ Header' | |
| '.rs' = 'Rust' | |
| '.swift' = 'Swift' | |
| '.kt' = 'Kotlin' | |
| '.kts' = 'Kotlin' | |
| '.m' = 'Objective-C' | |
| '.mm' = 'Objective-C++' | |
| '.scala' = 'Scala' | |
| '.sh' = 'Shell' | |
| '.bash' = 'Shell' | |
| '.zsh' = 'Shell' | |
| '.ps1' = 'PowerShell' | |
| '.psm1' = 'PowerShell' | |
| '.psd1' = 'PowerShell' | |
| '.sql' = 'SQL' | |
| '.html' = 'HTML' | |
| '.htm' = 'HTML' | |
| '.css' = 'CSS' | |
| '.scss' = 'SCSS' | |
| '.sass' = 'Sass' | |
| '.less' = 'LESS' | |
| '.json' = 'JSON' | |
| '.xml' = 'XML' | |
| '.yaml' = 'YAML' | |
| '.yml' = 'YAML' | |
| '.toml' = 'TOML' | |
| '.md' = 'Markdown' | |
| '.rst' = 'reStructuredText' | |
| '.r' = 'R' | |
| '.lua' = 'Lua' | |
| '.dart' = 'Dart' | |
| '.ex' = 'Elixir' | |
| '.exs' = 'Elixir' | |
| '.erl' = 'Erlang' | |
| '.clj' = 'Clojure' | |
| '.cljs' = 'ClojureScript' | |
| '.hs' = 'Haskell' | |
| '.vb' = 'Visual Basic' | |
| '.fs' = 'F#' | |
| '.fsx' = 'F#' | |
| '.pl' = 'Perl' | |
| '.pm' = 'Perl' | |
| '.groovy' = 'Groovy' | |
| '.gradle' = 'Groovy' | |
| '.tf' = 'Terraform' | |
| '.dockerfile' = 'Dockerfile' | |
| '.vue' = 'Vue' | |
| '.svelte' = 'Svelte' | |
| '(no extension)' = 'Unknown' | |
| } | |
| Add-Type -Language CSharp -TypeDefinition @' | |
| using System; | |
| using System.Collections.Concurrent; | |
| using System.Collections.Generic; | |
| using System.IO; | |
| using System.Text; | |
| using System.Text.RegularExpressions; | |
| using System.Threading.Tasks; | |
| namespace LocScanner | |
| { | |
| public static class Counter | |
| { | |
| public static ConcurrentDictionary<string, long[]> Scan( | |
| string rootPath, | |
| HashSet<string> excludedDirs, | |
| HashSet<string> binaryExtensions, | |
| string[] patterns) | |
| { | |
| var rules = new List<PatternRule>(); | |
| foreach (var raw in patterns) | |
| { | |
| if (!string.IsNullOrWhiteSpace(raw)) | |
| { | |
| rules.Add(ParsePattern(raw)); | |
| } | |
| } | |
| var files = new List<string>(); | |
| string rootFull = Path.GetFullPath(rootPath).TrimEnd('/', '\\'); | |
| CollectFiles(rootFull, rootFull, excludedDirs, rules, files); | |
| var result = new ConcurrentDictionary<string, long[]>(StringComparer.OrdinalIgnoreCase); | |
| Parallel.ForEach( | |
| files, | |
| new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount) }, | |
| () => new Dictionary<string, long[]>(StringComparer.OrdinalIgnoreCase), | |
| (file, loopState, local) => | |
| { | |
| string ext = Path.GetExtension(file); | |
| ext = string.IsNullOrEmpty(ext) ? "(no extension)" : ext.ToLowerInvariant(); | |
| if (binaryExtensions.Contains(ext)) | |
| { | |
| return local; | |
| } | |
| long lines; | |
| try | |
| { | |
| lines = CountLines(file); | |
| } | |
| catch (IOException) | |
| { | |
| return local; | |
| } | |
| catch (UnauthorizedAccessException) | |
| { | |
| return local; | |
| } | |
| long[] counts; | |
| if (!local.TryGetValue(ext, out counts)) | |
| { | |
| counts = new long[2]; | |
| local[ext] = counts; | |
| } | |
| counts[0] += 1; | |
| counts[1] += lines; | |
| return local; | |
| }, | |
| (local) => | |
| { | |
| foreach (var kvp in local) | |
| { | |
| result.AddOrUpdate( | |
| kvp.Key, | |
| kvp.Value, | |
| (key, existing) => | |
| { | |
| lock (existing) | |
| { | |
| existing[0] += kvp.Value[0]; | |
| existing[1] += kvp.Value[1]; | |
| } | |
| return existing; | |
| }); | |
| } | |
| }); | |
| return result; | |
| } | |
| private static void CollectFiles( | |
| string dir, | |
| string rootFull, | |
| HashSet<string> excludedDirs, | |
| List<PatternRule> rules, | |
| List<string> files) | |
| { | |
| try | |
| { | |
| foreach (var file in Directory.EnumerateFiles(dir)) | |
| { | |
| if (rules.Count > 0 && IsExcluded(file, rootFull, rules)) | |
| { | |
| continue; | |
| } | |
| files.Add(file); | |
| } | |
| } | |
| catch (UnauthorizedAccessException) { } | |
| catch (IOException) { } | |
| IEnumerable<string> subDirs; | |
| try | |
| { | |
| subDirs = Directory.EnumerateDirectories(dir); | |
| } | |
| catch (UnauthorizedAccessException) { return; } | |
| catch (IOException) { return; } | |
| foreach (var sub in subDirs) | |
| { | |
| string name = Path.GetFileName(sub); | |
| if (excludedDirs.Contains(name)) | |
| { | |
| continue; | |
| } | |
| CollectFiles(sub, rootFull, excludedDirs, rules, files); | |
| } | |
| } | |
| // Rules are evaluated in order (ignore-file patterns first, then -Exclude | |
| // patterns); the last matching rule wins, so a later '!' pattern can | |
| // re-include a path an earlier pattern excluded — same as .gitignore. | |
| private static bool IsExcluded(string file, string rootFull, List<PatternRule> rules) | |
| { | |
| string relative = file.Substring(rootFull.Length).TrimStart('/', '\\').Replace('\\', '/'); | |
| bool excluded = false; | |
| foreach (var rule in rules) | |
| { | |
| if (rule.Regex.IsMatch(relative)) | |
| { | |
| excluded = !rule.Negate; | |
| } | |
| } | |
| return excluded; | |
| } | |
| private struct PatternRule | |
| { | |
| public readonly Regex Regex; | |
| public readonly bool Negate; | |
| public PatternRule(Regex regex, bool negate) | |
| { | |
| Regex = regex; | |
| Negate = negate; | |
| } | |
| } | |
| // Parses a single gitignore-style pattern line into a compiled rule. | |
| // Supports: '!' negation, trailing '/' for directory-only matches, a | |
| // leading '/' (or any other internal '/') anchoring to the scan root, | |
| // and '*' / '?' / '**' wildcards. A pattern with no '/' (besides an | |
| // optional trailing one) matches at any depth, and any pattern that | |
| // matches a directory also matches everything under it. | |
| private static PatternRule ParsePattern(string raw) | |
| { | |
| string pattern = raw.Replace('\\', '/'); | |
| bool negate = false; | |
| if (pattern.Length > 0 && pattern[0] == '!') | |
| { | |
| negate = true; | |
| pattern = pattern.Substring(1); | |
| } | |
| bool dirOnly = pattern.EndsWith("/"); | |
| if (dirOnly) | |
| { | |
| pattern = pattern.Substring(0, pattern.Length - 1); | |
| } | |
| bool anchored = pattern.StartsWith("/"); | |
| if (anchored) | |
| { | |
| pattern = pattern.Substring(1); | |
| } | |
| else if (pattern.IndexOf('/') >= 0) | |
| { | |
| anchored = true; // a slash anywhere but the end anchors to the root | |
| } | |
| string body = (anchored ? "" : "(?:.*/)?") + GlobBody(pattern); | |
| // A pattern can match either a file directly, or a directory whose | |
| // contents should then all be considered matched too. | |
| string regexPattern = dirOnly | |
| ? "^" + body + "/.*$" | |
| : "^(?:" + body + "|" + body + "/.*)$"; | |
| return new PatternRule(new Regex(regexPattern, RegexOptions.IgnoreCase), negate); | |
| } | |
| // Translates glob wildcards (*, ?, **) into a regex body (no anchors). | |
| private static string GlobBody(string normalized) | |
| { | |
| var sb = new StringBuilder(); | |
| int i = 0; | |
| while (i < normalized.Length) | |
| { | |
| char c = normalized[i]; | |
| if (c == '*' && i + 1 < normalized.Length && normalized[i + 1] == '*') | |
| { | |
| bool precededBySlashOrStart = i == 0 || normalized[i - 1] == '/'; | |
| bool followedBySlash = i + 2 < normalized.Length && normalized[i + 2] == '/'; | |
| if (precededBySlashOrStart && followedBySlash) | |
| { | |
| sb.Append("(?:.*/)?"); | |
| i += 3; // consume "**/" | |
| } | |
| else | |
| { | |
| sb.Append(".*"); | |
| i += 2; // consume "**" | |
| } | |
| } | |
| else if (c == '*') | |
| { | |
| sb.Append("[^/]*"); | |
| i++; | |
| } | |
| else if (c == '?') | |
| { | |
| sb.Append("[^/]"); | |
| i++; | |
| } | |
| else | |
| { | |
| sb.Append(Regex.Escape(c.ToString())); | |
| i++; | |
| } | |
| } | |
| return sb.ToString(); | |
| } | |
| private static long CountLines(string path) | |
| { | |
| const int bufferSize = 1 << 20; // 1 MB | |
| long count = 0; | |
| bool sawAnyByte = false; | |
| bool lastByteWasNewline = false; | |
| byte[] buffer = new byte[bufferSize]; | |
| using (var stream = new FileStream( | |
| path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.SequentialScan)) | |
| { | |
| int bytesRead; | |
| while ((bytesRead = stream.Read(buffer, 0, bufferSize)) > 0) | |
| { | |
| sawAnyByte = true; | |
| for (int i = 0; i < bytesRead; i++) | |
| { | |
| if (buffer[i] == (byte)'\n') | |
| { | |
| count++; | |
| lastByteWasNewline = true; | |
| } | |
| else | |
| { | |
| lastByteWasNewline = false; | |
| } | |
| } | |
| } | |
| } | |
| if (sawAnyByte && !lastByteWasNewline) | |
| { | |
| count++; // count trailing partial line with no final newline | |
| } | |
| return count; | |
| } | |
| } | |
| } | |
| '@ | |
| $sw = [System.Diagnostics.Stopwatch]::StartNew() | |
| $scanResult = [LocScanner.Counter]::Scan($resolvedPath, $excludedDirs, $binaryExtensions, $allPatterns) | |
| $sw.Stop() | |
| $rows = @(foreach ($entry in $scanResult.GetEnumerator()) { | |
| $ext = $entry.Key | |
| $files = $entry.Value[0] | |
| $lines = $entry.Value[1] | |
| $language = $languageMap[$ext] | |
| if (-not $language) { | |
| $language = "Unknown ($ext)" | |
| } | |
| [pscustomobject]@{ | |
| Extension = $ext | |
| Language = $language | |
| Files = $files | |
| Lines = $lines | |
| } | |
| }) | |
| $rows = @($rows | Sort-Object -Property Lines -Descending) | |
| if (-not $rows) { | |
| Write-Host "No countable files found under $resolvedPath" | |
| return | |
| } | |
| $totalFiles = [int64](($rows | Measure-Object -Property Files -Sum).Sum) | |
| $totalLines = [int64](($rows | Measure-Object -Property Lines -Sum).Sum) | |
| $rows += [pscustomobject]@{ | |
| Extension = '' | |
| Language = 'TOTAL' | |
| Files = $totalFiles | |
| Lines = $totalLines | |
| } | |
| $rows | Format-Table -Property Extension, Language, Files, Lines -AutoSize | |
| Write-Host "Scanned '$resolvedPath' in $($sw.Elapsed.TotalSeconds.ToString('0.00'))s" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment