diff --git a/CLAUDE.md b/CLAUDE.md index 67a176b..1e446c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ The solution has three projects: 1. `DirectoryScanner` — discovers files matching include/exclude glob patterns; built-in excludes cover `bin`, `obj`, `.git`, `node_modules`, etc. Directory symlinks/junctions are never followed (loop protection); other reparse points without a link target (e.g. OneDrive Files-On-Demand folders and cloud files) are scanned as ordinary directories/files (`SymlinkGuard.IsLink`). When `ScanOptions.RespectGitignore` is set, `GitIgnoreRules` (nested `.gitignore` support plus the global `core.excludesFile` and repo-local `.git/info/exclude`, negation, anchoring, `**`, character classes negated with `!` or `^`, backslash escapes) filters the results. Both rule sets are anchored at the enclosing git working tree (`GitWorkTree.Find`, which also follows a `.git` file for linked worktrees/submodules and reads `config`/`info/exclude` from the common directory): rule-file bases are repository-relative, so scanning a subdirectory also applies the `.gitignore`/`.gitattributes` files above it up to the repository root, while the scan root and its ancestors are never treated as ignored. The rule files above the scan root are collected by `GitRuleFiles` (from disk, or from the commit's files for `ScanSnapshot`/`ScanSnapshotFile`), shared by the directory, snapshot, and single-file scans. With `RespectGitignore` (or `ScanOptions.SkipNestedRepositories`, set for `--compare-to` so `--no-gitignore` still matches the commit) set and the scan root inside a repository, `ScanTreeWalker` also skips nested repositories (subdirectories whose `.git` directory or `gitdir:` file is a valid repository, `GitWorkTree.IsRepositoryRoot`, e.g. submodules), reporting them as skipped, just as the `--git-hash`/`--compare-to` snapshot skips gitlink entries; an unfollowed directory link is left out as a link instead. When `ScanOptions.RespectGitAttributes` is set (default), `GitAttributesRules` (nested `.gitattributes` support, sharing `GitIgnoreRules`' glob-to-regex compiler) excludes files marked `linguist-vendored`/`linguist-generated`. `ScanOptions.IncludeLangs`/`ExcludeLangs` filter by resolved language name. `ScanFiles()` resolves an explicit file list (used by `--list-file`), bypassing globbing/recursion/`.gitignore`/`.gitattributes` entirely. `ScanSnapshot()` applies `Scan()`'s filtering to files given with a scan-root-relative path instead of a real tree (used by `--compare-to` for the extracted commit, passing a `SnapshotRepository` so the rule files above the scan root come from that commit rather than the working tree). 2. `FileAnalyzer` — reads a file (or in-memory text), delegates per-line classification to `LineClassifier` 3. `LineClassifier` — stateful classifier that tracks open block comments across lines, emitting a `LineKind` (Code / Comment / Blank) per line -4. `Languages/LanguageRegistry` — static map of file extensions → `LanguageDefinition` (comment tokens, block-comment pairs, health-display flag) and string-literal delimiters; 58 built-in languages. Resolution is by extension, with a filename lookup (`LanguageDefinition.Filenames`, checked first; case-insensitive unless `CaseSensitiveFilenames`, set for Bazel's `BUILD`/`WORKSPACE`) for files like `Makefile`, `Dockerfile`, `CMakeLists.txt` +4. `Languages/LanguageRegistry` — static map of file extensions → `LanguageDefinition` (comment tokens, block-comment pairs, health-display flag) and string-literal delimiters; 66 built-in languages. Resolution is by extension, with a filename lookup (`LanguageDefinition.Filenames`, checked first; case-insensitive unless `CaseSensitiveFilenames`, set for Bazel's `BUILD`/`WORKSPACE`) for files like `Makefile`, `Dockerfile`, `CMakeLists.txt` 5. `Models/` — `FileAnalysis` (per-file counts), `AnalysisSummary` (aggregated by language with comment-health percentiles), `SkippedEntry` (path + reason for files that could not be read) 6. `GitSnapshotExtractor` — used by `--git-hash`; shells out to `git` (`ls-tree`, `cat-file --batch`) to dump a commit/tree-ish's blobs into a temporary directory without checking it out; blobs whose path has a smudge filter driver (e.g. Git LFS, which may download) or a `working-tree-encoding` attribute (found via `check-attr`) are dumped one by one with `cat-file --filters` instead, so their content matches a checkout, and a failing filter skips the blob. It returns a `GitSnapshot` (temp-path ↔ git-relative-path pairs, plus symlinks/submodules as `SkippedEntry`). `AnalyzeHandler` scans the temp files via `ScanFiles()` like `--list-file`, then remaps result paths back to git-relative paths before rendering. @@ -45,11 +45,11 @@ The solution has three projects: ## CLI Options -`sloc [options]` — key flags: `--include`/`-i` and `--exclude`/`-e` (repeatable globs), `--exclude-dir ` (repeatable; shorthand for `--exclude "**//**"`), `--include-lang`/`--exclude-lang` (repeatable language display names, e.g. `"C#"`, matched case-insensitively against the resolved language), `--list-file ` (analyze exactly the files listed one-per-line in this file instead of scanning `path`; `-` reads the list from stdin), `--git-hash ` (analyze the repository tree as of this commit/tree-ish without checking it out; `path` is used as the repo root; mutually exclusive with `--list-file`; requires `git` on `PATH`; symlink and submodule tree entries are skipped and reported in the skipped list; `--include`/`--exclude` globs, `--no-recursive`, `--follow-symlinks`, `--no-gitignore`, and `--no-gitattributes` have no effect in this mode since `git ls-tree` only ever lists tracked blobs at that commit), `--format`/`-f` (`Table`/`Json`/`Html`/`Csv`/`Markdown`), `--output`/`-o` (format inferred from extension if `--format` omitted; `-` writes to stdout), `--no-recursive`, `--no-health`, `--by-file`, `--detailed` (Json/Html/Markdown: emit language summary and per-file breakdown together), `--paged`/`-p`, `--all` (include unknown extensions, grouped as `Other`), `--unique` (count byte-identical files only once; later duplicates are reported as skipped), `--quiet`/`-q`, `--no-progress`, `--watch`/`-w` (Table format only; re-scans and refreshes the live table when files under `path` change, debounced ~400ms, until Ctrl+C; mutually exclusive with `--git-hash`, `--list-file`, and `--baseline`), `--min-comment-pct` (threshold gate), `--jobs`/`-j` (parallelism; default processor count, `1` = sequential), `--no-gitignore` (`.gitignore`, `.git/info/exclude`, and the global `core.excludesFile` are honored by default), `--no-gitattributes` (files marked `linguist-vendored`/`linguist-generated` in `.gitattributes` are excluded by default), `--follow-symlinks` (descend into symlinked/junctioned directories instead of skipping them; a symlink that loops back to one of its own ancestors, or points at a directory containing one, is still skipped; links whose real target is inside `path` are skipped too, and an external file or directory reached through several links is counted once; paths are compared after resolving every link via `SymlinkGuard.GetRealPath`, so this holds even when `path` itself is a symlink/junction), `--baseline ` (diff against a saved report), `--compare-to ` (diff against `path` as of another commit/tree-ish, extracted via `GitSnapshotExtractor` the same way as `--git-hash` and scoped to the same subdirectory (or single file) as `path`, without checking it out or saving a baseline file first; unless combined with `--git-hash`, the commit's files go through `DirectoryScanner.ScanSnapshot`, which applies the same globs, built-in excludes, `--no-recursive`, and the commit's own `.gitignore`/`.gitattributes` as the working-tree `Scan()`, so unchanged files diff to zero; requires `git` on `PATH`; mutually exclusive with `--baseline`, `--watch`, and `--list-file`; combine with `--git-hash` to diff two commits directly; output is Table/Json only, same restriction as `--baseline`), `--sort `, `--top ` (limit language rows), `--no-update-check` (skip the GitHub release check). `Json`/`Html`/`Csv`/`Markdown` all default to stdout when `--output` is omitted. File analysis runs in parallel (`Parallel.For`), merged back in scan order so output is deterministic regardless of `--jobs`. Exit codes: `0` success, `1` path error, `2` threshold not met, `3` unexpected error (see `ExitCode` in `AnalyzeHandler.cs`). +`sloc [options]` — key flags: `--include`/`-i` and `--exclude`/`-e` (repeatable globs), `--exclude-dir ` (repeatable; shorthand for `--exclude "**//**"`), `--include-lang`/`--exclude-lang` (repeatable language display names, e.g. `"C#"`, matched case-insensitively against the resolved language), `--list-file ` (analyze exactly the files listed one-per-line in this file instead of scanning `path`; `-` reads the list from stdin), `--git-hash ` (analyze the repository tree as of this commit/tree-ish without checking it out; `path` is used as the repo root; mutually exclusive with `--list-file`; requires `git` on `PATH`; symlink and submodule tree entries are skipped and reported in the skipped list; `--include`/`--exclude` globs, `--no-recursive`, `--follow-symlinks`, `--no-gitignore`, and `--no-gitattributes` have no effect in this mode since `git ls-tree` only ever lists tracked blobs at that commit), `--format`/`-f` (`Table`/`Json`/`Html`/`Csv`/`Markdown`), `--output`/`-o` (format inferred from extension if `--format` omitted; `-` writes to stdout), `--no-recursive`, `--no-health`, `--by-file`, `--detailed` (Json/Html/Markdown: emit language summary and per-file breakdown together), `--paged`/`-p`, `--all` (include unknown extensions, grouped as `Other`), `--unique` (count byte-identical files only once; later duplicates are reported as skipped), `--quiet`/`-q`, `--no-progress`, `--watch`/`-w` (Table format only; re-scans and refreshes the live table when files under `path` change, debounced ~400ms, until Ctrl+C; mutually exclusive with `--git-hash`, `--list-file`, and `--baseline`), `--min-comment-pct` (threshold gate), `--jobs`/`-j` (parallelism; default processor count, `1` = sequential), `--no-gitignore` (`.gitignore`, `.git/info/exclude`, and the global `core.excludesFile` are honored by default), `--no-gitattributes` (files marked `linguist-vendored`/`linguist-generated` in `.gitattributes` are excluded by default), `--follow-symlinks` (descend into symlinked/junctioned directories instead of skipping them; a symlink that loops back to one of its own ancestors, or points at a directory containing one, is still skipped; links whose real target is inside `path` are skipped too, and an external file or directory reached through several links is counted once; paths are compared after resolving every link via `SymlinkGuard.GetRealPath`, so this holds even when `path` itself is a symlink/junction), `--baseline ` (diff against a saved report), `--compare-to ` (diff against `path` as of another commit/tree-ish, extracted via `GitSnapshotExtractor` the same way as `--git-hash` and scoped to the same subdirectory (or single file) as `path`, without checking it out or saving a baseline file first; unless combined with `--git-hash`, the commit's files go through `DirectoryScanner.ScanSnapshot`, which applies the same globs, built-in excludes, `--no-recursive`, and the commit's own `.gitignore`/`.gitattributes` as the working-tree `Scan()`, so unchanged files diff to zero; requires `git` on `PATH`; mutually exclusive with `--baseline`, `--watch`, and `--list-file`; combine with `--git-hash` to diff two commits directly; output is Table/Json only, same restriction as `--baseline`), `--sort `, `--top ` (limit language rows; the totals still cover every language, so Table/Markdown/Html add a "Showing top N of M languages" note and Json adds `languageCount` next to `byLanguageTruncated`, while Csv stays note-free to keep fixed columns), `--no-update-check` (skip the GitHub release check). `Json`/`Html`/`Csv`/`Markdown` all default to stdout when `--output` is omitted. File analysis runs in parallel (`Parallel.For`), merged back in scan order so output is deterministic regardless of `--jobs`. Exit codes: `0` success, `1` path error, `2` threshold not met, `3` unexpected error (see `ExitCode` in `AnalyzeHandler.cs`). ## Known Limitations -Line classification is text-based, not a full lexer. String literals are tracked for most languages (via `LanguageDefinition.StringLiterals`), so comment markers inside strings (e.g. `"// not a comment"`) count as code and escaped quotes are handled, including asymmetric open/close delimiters and doubled-closing-delimiter escaping (`StringLiteral.CloseDelimiter`/`DoubledClosingEscape`, used for C# verbatim strings `@"…""…"` and Rust raw strings `r"…"`/`r#"…"#`; C# raw string literals and Java text blocks `"""…"""` are multi-line literals); block comments can nest where the language allows (`BlockComment.AllowNested`, e.g. Rust/F#/Swift). JavaScript-family regex literals (`LanguageDefinition.RegexLiterals`, e.g. `/\/*$/`) are recognized heuristically: a `/` starts one only where an operand is expected (not after an identifier, number, `)`, `]`, or string) and the literal closes on the same line. `LanguageDefinition.LineCommentExceptions` lists tokens that start like a line comment but are not one (PHP 8 attributes `#[…]`); `LanguageDefinition.BlockCommentExceptions` does the same for block-comment opens (F#'s `(*)` operator, which neither opens nor nests a comment). C/C++ digit separators (`1'000`, `LanguageDefinition.QuoteDigitSeparators`) do not open a character literal. Not modeled: Rust raw strings with more than one `#`, C# raw strings delimited by four or more quotes, C++ raw strings with a custom delimiter (`R"x(…)x"`; only `R"(…)"` is recognized), Lua long-bracket comments beyond level 4 (`--[=====[`), Perl POD blocks opened by commands other than the common ones (`=pod`, `=head1`–`=head6`, `=over`, `=item`, `=back`, `=begin`, `=end`, `=for`, `=encoding`), and string interpolation expressions. The complexity metric's alphabetic keywords match whole words only (so single-word forms like `foreach`/`elseif`/`elif` are listed explicitly), and C-style ternaries (`a ? b : c`) are not counted: the `?:` token only matches that literal operator (e.g. PHP's shorthand ternary). Tokens are matched textually, so ones that are not branches in context still inflate the count: TypeScript's optional-member `?:` (`name?: string`), Rust's empty closure parameters `||`, and C++'s rvalue/forwarding reference `&&` (`T&&`). Python triple-quoted literals count as comments only when they begin a statement (docstrings, optionally prefixed with `r`/`u`, `LanguageDefinition.DocStringPrefixes`); as a value (`x = """…"""`), inside brackets left open by an earlier line, or after a trailing `\` continuation they count as code. +Line classification is text-based, not a full lexer. String literals are tracked for most languages (via `LanguageDefinition.StringLiterals`), so comment markers inside strings (e.g. `"// not a comment"`) count as code and escaped quotes are handled, including asymmetric open/close delimiters and doubled-closing-delimiter escaping (`StringLiteral.CloseDelimiter`/`DoubledClosingEscape`, used for C# verbatim strings `@"…""…"` and Rust raw strings `r"…"`/`r#"…"#`; C# raw string literals and Java text blocks `"""…"""` are multi-line literals); block comments can nest where the language allows (`BlockComment.AllowNested`, e.g. Rust/F#/Swift). JavaScript-family regex literals (`LanguageDefinition.RegexLiterals`, e.g. `/\/*$/`) are recognized heuristically: a `/` starts one only where an operand is expected (not after an identifier, number, `)`, `]`, or string) and the literal closes on the same line. `LanguageDefinition.LineCommentExceptions` lists tokens that start like a line comment but are not one (PHP 8 attributes `#[…]`); `LanguageDefinition.BlockCommentExceptions` does the same for block-comment opens (F#'s `(*)` operator, which neither opens nor nests a comment). C/C++ digit separators (`1'000`, `LanguageDefinition.QuoteDigitSeparators`) do not open a character literal. Shell strings do not span lines (an unbalanced quote in a heredoc or `echo don't` would otherwise swallow the rest of the file), so a `#` line inside a multi-line string, heredoc, or awk script is counted as a comment. Not modeled: Rust raw strings with more than one `#`, C# raw strings delimited by four or more quotes, C++ raw strings with a custom delimiter (`R"x(…)x"`; only `R"(…)"` is recognized), Lua long-bracket comments beyond level 4 (`--[=====[`), Perl POD blocks opened by commands other than the common ones (`=pod`, `=head1`–`=head6`, `=over`, `=item`, `=back`, `=begin`, `=end`, `=for`, `=encoding`), and string interpolation expressions. The complexity metric's alphabetic keywords match whole words only (so single-word forms like `foreach`/`elseif`/`elif` are listed explicitly), and C-style ternaries (`a ? b : c`) are not counted: the `?:` token only matches that literal operator (e.g. PHP's shorthand ternary). Tokens are matched textually, so ones that are not branches in context still inflate the count: TypeScript's optional-member `?:` (`name?: string`), Rust's empty closure parameters `||`, and C++'s rvalue/forwarding reference `&&` (`T&&`). Python triple-quoted literals count as comments only when they begin a statement (docstrings, optionally prefixed with `r`/`u`, `LanguageDefinition.DocStringPrefixes`); as a value (`x = """…"""`), inside brackets left open by an earlier line, or after a trailing `\` continuation they count as code. `--git-hash` requires a `git` executable on `PATH` (no bundled/vendored git); `--include`/`--exclude` glob filtering does not apply in this mode (same limitation as `--list-file`); git symlink and submodule (gitlink) tree entries are always skipped, never analyzed. diff --git a/Directory.Build.props b/Directory.Build.props index 498a9b5..b57d880 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ enable true latest - true + true en embedded diff --git a/README.md b/README.md index d2df34e..c7d076b 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ sloc . --format markdown --detailed -o - | `--baseline` | | Compare against a previously saved JSON report and show the line-count diff | | `--compare-to` | | Diff the current analysis against `path` as of this other commit/tree-ish, without checking it out or saving a baseline file first; requires `git` on `PATH`; `path` may be a directory or a single file; the commit's files are filtered the same way as the working tree (`--include`/`--exclude`, `.gitignore`, `.gitattributes`, …) so unchanged files diff to zero; combine with `--git-hash` to diff two commits directly; mutually exclusive with `--baseline`, `--watch`, and `--list-file` | | `--sort` | | Order the language summary by `Total` (default), `Code`, `Comment`, `Blank`, `Files`, `Name`, or `CommentPct` | -| `--top` | | Show only the top N languages in the summary | +| `--top` | | Show only the top N languages in the summary; totals still cover all languages (noted in Table/Markdown/Html output, and as `languageCount` in Json) | | `--no-update-check` | | Do not check GitHub for a newer release (checked by default, with a 2 second timeout) | | `--help` | `-h` | Show help | | `--version` | | Show version | @@ -243,6 +243,7 @@ sloc ./src - Line classification is based on text matching of comment symbols, not a full lexer. - String literals are recognized for most languages, so comment markers inside strings (e.g. `"// not a comment"`) are counted as code, and escaped quotes are handled, including C# verbatim strings (`@"…""…"`), C# raw string literals and Java text blocks (`"""…"""`), Rust raw strings (`r"…"`, `r#"…"#`), and C++ raw strings (`R"(…)"`). Rust raw strings with more than one `#`, C# raw strings delimited by four or more quotes, and C++ raw strings with a custom delimiter (`R"x(…)x"`) are not modeled, and interpolation expressions inside strings are not analyzed. C/C++ digit separators (`1'000`) are not mistaken for character literals. +- Shell strings do not span lines, so a line starting with `#` inside a multi-line string, heredoc, or awk script is counted as a comment. - Lua long-bracket comments are recognized up to level 4 (`--[====[ … ]====]`), and Perl POD blocks only when opened by a common command (`=pod`, `=head1`–`=head6`, `=over`, `=item`, `=back`, `=begin`, `=end`, `=for`, `=encoding`) at column 0. - JavaScript/TypeScript regex literals are recognized, so comment markers inside them (e.g. `/\/*$/`) are counted as code, and PHP 8 attributes (`#[…]`) are code rather than `#` comments. - Python triple-quoted strings are counted as comments only when they begin a statement (docstrings, including `r"""…"""`/`u"""…"""`); used as a value (e.g. `x = """…"""`), inside brackets left open by an earlier line, or after a trailing `\` line continuation they are counted as code. @@ -420,7 +421,7 @@ sloc . --format markdown --detailed -o - | `--baseline` | | 与之前保存的 JSON 报告对比,显示行数增减 | | `--compare-to` | | 将当前分析与 `path` 在另一个 commit/tree-ish 时的状态对比,无需检出该 commit 或先保存基线文件;需要 `git` 在 `PATH` 中;`path` 可以是目录或单个文件;该 commit 的文件会按与工作区相同的规则过滤(`--include`/`--exclude`、`.gitignore`、`.gitattributes` 等),未改动的文件差异为零;可与 `--git-hash` 组合直接对比两个 commit;与 `--baseline`、`--watch`、`--list-file` 互斥 | | `--sort` | | 语言汇总排序依据:`Total`(默认)、`Code`、`Comment`、`Blank`、`Files`、`Name` 或 `CommentPct` | -| `--top` | | 仅显示汇总中排名前 N 的语言 | +| `--top` | | 仅显示汇总中排名前 N 的语言;合计仍包含所有语言(Table/Markdown/Html 输出会注明,Json 输出为 `languageCount` 字段) | | `--no-update-check` | | 不检查 GitHub 上是否有新版本(默认检查,超时时间为 2 秒) | | `--help` | `-h` | 显示帮助 | | `--version` | | 显示版本 | diff --git a/src/Sloc.Cli/Output/ColumnLayout.cs b/src/Sloc.Cli/Output/ColumnLayout.cs index 3a4a024..a357985 100644 --- a/src/Sloc.Cli/Output/ColumnLayout.cs +++ b/src/Sloc.Cli/Output/ColumnLayout.cs @@ -50,6 +50,17 @@ public static void AddOptional(List row, bool noHealth, bool noComplexit } } + /// + /// Describes a --top-truncated language table, whose Total row still covers every + /// language, so a reader does not take the listed rows for the whole. + /// + /// The summary whose language table is rendered. + /// The note, or when every language is listed. + public static string? TruncationNote(AnalysisSummary summary) => + summary.ByLanguageTruncated + ? $"Showing top {summary.ByLanguage.Count} of {summary.LanguageCount} languages; totals cover all languages." + : null; + /// /// Renders a comment-health bucket as plain text, or an empty cell when the language /// does not support comment-health classification. diff --git a/src/Sloc.Cli/Output/HtmlRenderer.cs b/src/Sloc.Cli/Output/HtmlRenderer.cs index 120e5f9..eac8ae7 100644 --- a/src/Sloc.Cli/Output/HtmlRenderer.cs +++ b/src/Sloc.Cli/Output/HtmlRenderer.cs @@ -22,6 +22,8 @@ public sealed class HtmlRenderer : IResultRenderer h1 { font-size: 1.75rem; border-bottom: 1px solid #e1e4e8; padding-bottom: 10px; margin: 0 0 4px 0; } h2 { font-size: 1.1rem; margin-top: 32px; margin-bottom: 10px; } .meta { color: #586069; font-size: .875rem; margin-bottom: 24px; } + /* Pulled up against the preceding table: the -20px cancels most of table's 28px margin-bottom, leaving 8px. */ + .table-note { color: #586069; font-size: .8rem; margin: -20px 0 28px 0; } table { border-collapse: collapse; width: 100%; margin-bottom: 28px; font-size: .875rem; } th { background: #f6f8fa; border: 1px solid #d0d7de; padding: 8px 12px; white-space: nowrap; font-weight: 600; } td { border: 1px solid #d0d7de; padding: 8px 12px; } @@ -63,7 +65,7 @@ public sealed class HtmlRenderer : IResultRenderer @media (prefers-color-scheme: dark) { body { color: #e6edf3; background: #0d1117; } h1 { border-bottom-color: #30363d; } - .meta { color: #8b949e; } + .meta, .table-note { color: #8b949e; } th { background: #161b22; border-color: #30363d; } td { border-color: #30363d; } tr:hover > td { background: #161b22; } @@ -367,6 +369,11 @@ private static void BuildLanguageSection(StringBuilder sb, AnalysisSummary summa } sb.AppendLine(""); sb.AppendLine(""); + + if (ColumnLayout.TruncationNote(summary) is { } note) + { + sb.AppendLine($"

{Encode(note)}

"); + } } private static void BuildSkippedSection(StringBuilder sb, AnalysisSummary summary) diff --git a/src/Sloc.Cli/Output/JsonRenderer.cs b/src/Sloc.Cli/Output/JsonRenderer.cs index 04751b4..a0a3460 100644 --- a/src/Sloc.Cli/Output/JsonRenderer.cs +++ b/src/Sloc.Cli/Output/JsonRenderer.cs @@ -66,6 +66,7 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det }).ToList(), // Recorded so a --baseline diff knows the breakdown is partial (see DiffRenderer). ByLanguageTruncated = includeLanguages && summary.ByLanguageTruncated ? true : null, + LanguageCount = includeLanguages && summary.ByLanguageTruncated ? summary.LanguageCount : null, Files = includeFiles ? summary.Files.Select(file => new JsonFile { @@ -280,6 +281,15 @@ public int FileCount get; init; } + /// + /// The number of languages the totals cover, written only when + /// was cut short by --top. + /// + public int? LanguageCount + { + get; init; + } + public IReadOnlyList? Files { get; init; diff --git a/src/Sloc.Cli/Output/MarkdownRenderer.cs b/src/Sloc.Cli/Output/MarkdownRenderer.cs index 33f20fe..d496ecd 100644 --- a/src/Sloc.Cli/Output/MarkdownRenderer.cs +++ b/src/Sloc.Cli/Output/MarkdownRenderer.cs @@ -161,6 +161,12 @@ private static void AppendLanguageTable(StringBuilder sb, AnalysisSummary summar ColumnLayout.AddOptional(totalRow, noHealth, noComplexity, string.Empty, ComplexityCell(summary.ComplexityTotal)); AppendRow(sb, totalRow); + + if (ColumnLayout.TruncationNote(summary) is { } note) + { + sb.AppendLine(); + sb.AppendLine($"_{note}_"); + } } private static void AppendRow(StringBuilder sb, IReadOnlyList cells) diff --git a/src/Sloc.Cli/Output/TableRenderer.cs b/src/Sloc.Cli/Output/TableRenderer.cs index 979f51b..76da1ae 100644 --- a/src/Sloc.Cli/Output/TableRenderer.cs +++ b/src/Sloc.Cli/Output/TableRenderer.cs @@ -46,9 +46,11 @@ internal Table BuildLanguageTable(AnalysisSummary summary, string? caption = nul { var table = new Table().Border(TableBorder.Rounded); - if (caption is not null) + var note = ColumnLayout.TruncationNote(summary) is { } text ? $"[grey]{Markup.Escape(text)}[/]" : null; + var fullCaption = caption is not null && note is not null ? $"{caption}{Environment.NewLine}{note}" : caption ?? note; + if (fullCaption is not null) { - table.Caption(new TableTitle(caption)); + table.Caption(new TableTitle(fullCaption)); } table.AddColumn("Language"); diff --git a/src/Sloc.Cli/Parsing/CliArgumentValidation.cs b/src/Sloc.Cli/Parsing/CliArgumentValidation.cs index be2342b..47aac4d 100644 --- a/src/Sloc.Cli/Parsing/CliArgumentValidation.cs +++ b/src/Sloc.Cli/Parsing/CliArgumentValidation.cs @@ -1,4 +1,5 @@ using Sloc.Cli.Analysis; +using Sloc.Core.Languages; namespace Sloc.Cli.Parsing; @@ -26,6 +27,38 @@ public static bool IsValidMinCommentPct(double? value) => public static bool IsValidTop(int? value) => value is null or >= 1; + /// + /// Determines whether a --format value names a defined . + /// System.CommandLine also accepts numeric text such as 99 for an enum option, which + /// would otherwise silently fall back to Table output. A value (the + /// option was not supplied) is always valid. + /// + /// The parsed --format value, if any. + /// if the value is valid; otherwise . + public static bool IsValidFormat(OutputFormat? value) => + value is not { } format || Enum.IsDefined(format); + + /// + /// Finds the first language name that is neither a built-in language nor Other (the + /// group --all puts unrecognized extensions in), compared case-insensitively. + /// + /// The values given to --include-lang or --exclude-lang. + /// The first unknown name as written, or if all are known. + public static string? FindUnknownLanguage(IEnumerable names) + { + foreach (var name in names) + { + var known = string.Equals(name, "Other", StringComparison.OrdinalIgnoreCase) + || LanguageRegistry.Languages.Any(language => string.Equals(language.Name, name, StringComparison.OrdinalIgnoreCase)); + if (!known) + { + return name; + } + } + + return null; + } + /// /// Validates --watch's mutual exclusivity with --git-hash, --list-file, /// non-Table formats, and --baseline. diff --git a/src/Sloc.Cli/Parsing/SlocCommandFactory.cs b/src/Sloc.Cli/Parsing/SlocCommandFactory.cs index 57d9083..c84b4ed 100644 --- a/src/Sloc.Cli/Parsing/SlocCommandFactory.cs +++ b/src/Sloc.Cli/Parsing/SlocCommandFactory.cs @@ -180,7 +180,7 @@ public static RootCommand Create(Func execute) var topOption = new Option("--top") { - Description = "Show only the top N languages in the summary." + Description = "Show only the top N languages in the summary. The totals still cover every language; Table, Markdown, and Html output say so, Csv does not." }; var noUpdateCheckOption = new Option("--no-update-check") @@ -238,6 +238,11 @@ public static RootCommand Create(Func execute) { var outputFile = parseResult.GetValue(outputOption); var explicitFormat = parseResult.GetValue(formatOption); + if (!CliArgumentValidation.IsValidFormat(explicitFormat)) + { + Console.Error.WriteLine("sloc: --format must be Table, Json, Html, Csv, or Markdown."); + return ExitCode.Error; + } var format = FormatResolver.Resolve(explicitFormat, outputFile); @@ -269,6 +274,15 @@ public static RootCommand Create(Func execute) return ExitCode.Error; } + var includeLangs = parseResult.GetValue(includeLangOption) ?? []; + var excludeLangs = parseResult.GetValue(excludeLangOption) ?? []; + if (CliArgumentValidation.FindUnknownLanguage([.. includeLangs, .. excludeLangs]) is { } unknownLanguage) + { + Console.Error.WriteLine( + $"sloc: unknown language '{unknownLanguage}' for --include-lang/--exclude-lang. See --help for the supported names."); + return ExitCode.Error; + } + var listFile = parseResult.GetValue(listFileOption); var gitHash = parseResult.GetValue(gitHashOption); if (listFile is not null && gitHash is not null) @@ -305,8 +319,8 @@ public static RootCommand Create(Func execute) .. parseResult.GetValue(excludeOption) ?? [], .. (parseResult.GetValue(excludeDirOption) ?? []).Select(name => $"**/{name}/**") ], - IncludeLangs = parseResult.GetValue(includeLangOption) ?? [], - ExcludeLangs = parseResult.GetValue(excludeLangOption) ?? [], + IncludeLangs = includeLangs, + ExcludeLangs = excludeLangs, Format = format, NoRecursive = parseResult.GetValue(noRecursiveOption), ByFile = parseResult.GetValue(byFileOption), diff --git a/src/Sloc.Cli/Sloc.Cli.csproj b/src/Sloc.Cli/Sloc.Cli.csproj index 2ade5f9..97b4191 100644 --- a/src/Sloc.Cli/Sloc.Cli.csproj +++ b/src/Sloc.Cli/Sloc.Cli.csproj @@ -24,7 +24,7 @@ sloc Sloc coldhighsun - A .NET global CLI tool that counts code, comment, and blank lines across source files. Supports 44 languages with auto-detection, five output formats (table, JSON, HTML, CSV, Markdown), per-file detail view, and comment health indicators. + A .NET global CLI tool that counts code, comment, and blank lines across source files. Supports 66 languages with auto-detection, five output formats (table, JSON, HTML, CSV, Markdown), per-file detail view, and comment health indicators. cloc;cli;sloc;lines-of-code;loc;statistics;tool https://github.com/coldhighsun/Sloc https://github.com/coldhighsun/Sloc diff --git a/src/Sloc.Core/Models/AnalysisSummary.cs b/src/Sloc.Core/Models/AnalysisSummary.cs index d3519e8..30e9f90 100644 --- a/src/Sloc.Core/Models/AnalysisSummary.cs +++ b/src/Sloc.Core/Models/AnalysisSummary.cs @@ -195,6 +195,7 @@ public AnalysisSummary( .ToList(); ByLanguage = OrderAndLimit(grouped, sortBy, descending, top); + LanguageCount = grouped.Count; ByLanguageTruncated = ByLanguage.Count < grouped.Count; } @@ -309,9 +310,19 @@ public AnalysisSummary( ByLanguage = top is { } limit && limit >= 1 && limit < byLanguage.Count ? byLanguage.Take(limit).ToList() : byLanguage; + LanguageCount = byLanguage.Count; ByLanguageTruncated = ByLanguage.Count < byLanguage.Count; } + /// + /// The number of distinct languages the overall totals cover, including any that a + /// top limit left out of . + /// + public int LanguageCount + { + get; + } + /// /// Whether was cut short by a top limit, so it doesn't /// list every language the overall totals cover. diff --git a/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs b/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs index 9702b52..b75b911 100644 --- a/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs +++ b/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs @@ -39,6 +39,34 @@ public void IsValidTop_RequiresAtLeastOne(int? value, bool expected) Assert.Equal(expected, CliArgumentValidation.IsValidTop(value)); } + /// + /// Verifies that --format accepts a missing value and every defined format, and + /// rejects an undefined numeric value. + /// + [Theory] + [InlineData(null, true)] + [InlineData(OutputFormat.Table, true)] + [InlineData(OutputFormat.Markdown, true)] + [InlineData((OutputFormat)99, false)] + [InlineData((OutputFormat)(-1), false)] + public void IsValidFormat_RequiresDefinedValue(OutputFormat? value, bool expected) + { + Assert.Equal(expected, CliArgumentValidation.IsValidFormat(value)); + } + + /// + /// Verifies that built-in language names (any case) and Other are known, and that + /// the first unrecognized name is reported as written. + /// + [Theory] + [InlineData(new[] { "C#", "python", "OTHER" }, null)] + [InlineData(new string[0], null)] + [InlineData(new[] { "C#", "csharp", "nope" }, "csharp")] + public void FindUnknownLanguage_ReportsFirstUnknownName(string[] names, string? expected) + { + Assert.Equal(expected, CliArgumentValidation.FindUnknownLanguage(names)); + } + /// /// When --watch is not supplied, no other option combination should be rejected. /// diff --git a/tests/Sloc.Cli.Tests/ColumnLayoutTests.cs b/tests/Sloc.Cli.Tests/ColumnLayoutTests.cs index 455fa46..002b68e 100644 --- a/tests/Sloc.Cli.Tests/ColumnLayoutTests.cs +++ b/tests/Sloc.Cli.Tests/ColumnLayoutTests.cs @@ -86,6 +86,22 @@ public void AddOptional_ComplexitySuppressed_OmitsComplexityCell() Assert.Equal(["base", "Good"], row); } + /// + /// Verifies that the truncation note appears only when top left languages out, and + /// names both the listed and total language counts. + /// + [Fact] + public void TruncationNote_OnlyWhenLanguagesWereTrimmed() + { + var files = TestData.TwoLanguageFiles(); + + Assert.Null(ColumnLayout.TruncationNote(new AnalysisSummary(files))); + Assert.Null(ColumnLayout.TruncationNote(new AnalysisSummary(files, top: 2))); + Assert.Equal( + "Showing top 1 of 2 languages; totals cover all languages.", + ColumnLayout.TruncationNote(new AnalysisSummary(files, top: 1))); + } + /// /// Verifies that renders as an empty /// cell, and every other level renders as its own name. diff --git a/tests/Sloc.Cli.Tests/CsvRendererTests.cs b/tests/Sloc.Cli.Tests/CsvRendererTests.cs index 1755aae..906bf93 100644 --- a/tests/Sloc.Cli.Tests/CsvRendererTests.cs +++ b/tests/Sloc.Cli.Tests/CsvRendererTests.cs @@ -25,6 +25,24 @@ public void Render_ByLanguage_WritesHeaderAndRows() Assert.StartsWith("C#,1,80,20,5,105,", lines[1]); } + /// + /// Verifies that a top-truncated summary renders as a plain table with no extra + /// note line, so fixed-column consumers keep parsing it. + /// + [Fact] + public void Render_TruncatedLanguages_HasNoNoteRow() + { + var summary = new AnalysisSummary(TestData.TwoLanguageFiles(), top: 1); + using var writer = new StringWriter(); + + new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true, noComplexity: true); + var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(3, lines.Length); + Assert.All(lines, line => Assert.Equal(5, line.Count(c => c == ','))); + Assert.DoesNotContain("Showing top", writer.ToString()); + } + /// /// Verifies that noHealth drops the Health column from the header and rows. /// diff --git a/tests/Sloc.Cli.Tests/HtmlRendererTests.cs b/tests/Sloc.Cli.Tests/HtmlRendererTests.cs index e88c30c..3666d70 100644 --- a/tests/Sloc.Cli.Tests/HtmlRendererTests.cs +++ b/tests/Sloc.Cli.Tests/HtmlRendererTests.cs @@ -96,6 +96,44 @@ public void Render_ByFileCaseVariantFolders_KeepsThemSeparate() Assert.Contains("src", html); } + /// + /// Verifies that a top-truncated language table is followed by a note that the + /// totals cover all languages, and that an untruncated one has no note. + /// + [Fact] + public void Render_TruncatedLanguages_WritesNote() + { + var files = TestData.TwoLanguageFiles(); + using var truncated = new StringWriter(); + using var complete = new StringWriter(); + + new HtmlRenderer(truncated).Render(new AnalysisSummary(files, top: 1), byFile: false, noHealth: true); + new HtmlRenderer(complete).Render(new AnalysisSummary(files), byFile: false, noHealth: true); + + Assert.Contains("Showing top 1 of 2 languages; totals cover all languages.", truncated.ToString()); + Assert.DoesNotContain("Showing top", complete.ToString()); + } + + /// + /// Verifies that in detailed output the truncation note is a table-note paragraph + /// placed after the language table and before the By File section. + /// + [Fact] + public void Render_DetailedTruncatedLanguages_PlacesNoteBetweenTables() + { + using var writer = new StringWriter(); + + new HtmlRenderer(writer).Render( + new AnalysisSummary(TestData.TwoLanguageFiles(), top: 1), byFile: false, noHealth: true, detailed: true); + var html = writer.ToString(); + + var languageTable = html.IndexOf("

By Language

", StringComparison.Ordinal); + var note = html.IndexOf("

Showing top 1 of 2 languages", StringComparison.Ordinal); + var fileSection = html.IndexOf("

By File

", StringComparison.Ordinal); + Assert.True(languageTable >= 0 && languageTable < note, "note should follow the language table"); + Assert.True(note < fileSection, "note should precede the By File section"); + } + private static AnalysisSummary BuildSummary() { var file = new FileAnalysis diff --git a/tests/Sloc.Cli.Tests/JsonRendererTests.cs b/tests/Sloc.Cli.Tests/JsonRendererTests.cs index 90a3e70..f1e5b54 100644 --- a/tests/Sloc.Cli.Tests/JsonRendererTests.cs +++ b/tests/Sloc.Cli.Tests/JsonRendererTests.cs @@ -25,6 +25,22 @@ public void Render_ByFile_EmitsFilesAndOmitsByLanguage() Assert.Equal("a.cs", files[0].GetProperty("path").GetString()); } + /// + /// Verifies that a top-truncated report records how many languages the totals cover, + /// and that a complete one omits the field. + /// + [Fact] + public void Render_TruncatedLanguages_EmitsLanguageCount() + { + var files = TestData.TwoLanguageFiles(); + + var truncated = Render(new AnalysisSummary(files, top: 1), byFile: false, noHealth: true); + var complete = Render(new AnalysisSummary(files), byFile: false, noHealth: true); + + Assert.Equal(2, truncated.GetProperty("languageCount").GetInt32()); + Assert.False(complete.TryGetProperty("languageCount", out _)); + } + /// /// Verifies that the by-language payload includes totals, percentages, and health, /// and omits the per-file array. diff --git a/tests/Sloc.Cli.Tests/LiveAggregatorTests.cs b/tests/Sloc.Cli.Tests/LiveAggregatorTests.cs index 030dd36..b2c1803 100644 --- a/tests/Sloc.Cli.Tests/LiveAggregatorTests.cs +++ b/tests/Sloc.Cli.Tests/LiveAggregatorTests.cs @@ -30,6 +30,26 @@ public void Add_MultipleFilesSameLanguage_AccumulatesCounts() Assert.Equal(1, stats.Blank); } + /// + /// Verifies that a top limit trims the listed rows while the summary still reports + /// every language, so the live table can show its truncation note. + /// + [Fact] + public void ToSummary_WithTop_KeepsFullLanguageCount() + { + var aggregator = new LiveAggregator(LanguageSort.Total, top: 1); + + aggregator.Add(new FileAnalysis { Language = "C#", Path = "a.cs", Code = 10, Comment = 0, Blank = 0 }); + aggregator.Add(new FileAnalysis { Language = "Python", Path = "a.py", Code = 2, Comment = 0, Blank = 0 }); + + var summary = aggregator.ToSummary(); + + Assert.Single(summary.ByLanguage); + Assert.Equal(2, summary.LanguageCount); + Assert.True(summary.ByLanguageTruncated); + Assert.Equal(12, summary.Code); + } + /// /// Verifies that files in different languages are aggregated into separate rows. /// diff --git a/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs b/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs index 3c0ad93..b56588f 100644 --- a/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs +++ b/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs @@ -168,6 +168,22 @@ public void Render_WithSkipped_AppendsSkippedSection() Assert.Contains("- bad.bin — binary file", text); } + /// + /// Verifies that a top-truncated language table is followed by a note that the + /// totals cover all languages, and that an untruncated one has no note. + /// + [Fact] + public void Render_TruncatedLanguages_WritesNote() + { + var files = TestData.TwoLanguageFiles(); + + var truncated = Render(new AnalysisSummary(files, top: 1), byFile: false, noHealth: true); + var complete = Render(new AnalysisSummary(files), byFile: false, noHealth: true); + + Assert.Contains("_Showing top 1 of 2 languages; totals cover all languages._", truncated); + Assert.DoesNotContain("Showing top", complete); + } + private static string Render(AnalysisSummary summary, bool byFile, bool noHealth, bool noComplexity = false) { using var writer = new StringWriter(); diff --git a/tests/Sloc.Cli.Tests/SlocCommandFactoryTests.cs b/tests/Sloc.Cli.Tests/SlocCommandFactoryTests.cs index bcb8913..18af4aa 100644 --- a/tests/Sloc.Cli.Tests/SlocCommandFactoryTests.cs +++ b/tests/Sloc.Cli.Tests/SlocCommandFactoryTests.cs @@ -105,6 +105,48 @@ public void Create_MinCommentPctOutOfRange_ReturnsErrorWithoutExecuting() Assert.Null(options); } + /// + /// Verifies that an undefined numeric --format is rejected before the analysis runs + /// instead of silently rendering a table. + /// + [Fact] + public void Create_UndefinedFormatNumber_ReturnsErrorWithoutExecuting() + { + var (options, exitCode) = Run(["--format", "99"]); + + Assert.Equal(ExitCode.Error, exitCode); + Assert.Null(options); + } + + /// + /// Verifies that a misspelled language name for --include-lang or + /// --exclude-lang is rejected before the analysis runs. + /// + /// The language option. + [Theory] + [InlineData("--include-lang")] + [InlineData("--exclude-lang")] + public void Create_UnknownLanguage_ReturnsErrorWithoutExecuting(string option) + { + var (options, exitCode) = Run([option, "csharp"]); + + Assert.Equal(ExitCode.Error, exitCode); + Assert.Null(options); + } + + /// + /// Verifies that language names are matched case-insensitively and Other is accepted. + /// + [Fact] + public void Create_KnownLanguagesInAnyCase_AreAccepted() + { + var (options, exitCode) = Run(["--include-lang", "c#", "--exclude-lang", "other"]); + + Assert.Equal(ExitCode.Success, exitCode); + Assert.NotNull(options); + Assert.Equal(["c#"], options.IncludeLangs); + } + /// /// Parses against a command whose action records the options it /// receives instead of analyzing anything. diff --git a/tests/Sloc.Cli.Tests/TableRendererTests.cs b/tests/Sloc.Cli.Tests/TableRendererTests.cs index 6d5a7a1..483613e 100644 --- a/tests/Sloc.Cli.Tests/TableRendererTests.cs +++ b/tests/Sloc.Cli.Tests/TableRendererTests.cs @@ -122,6 +122,35 @@ public void BuildFileTable_FileOnOtherDrive_LabelsDriveRootFolder() Assert.Contains("📁 proj", output); } + /// + /// Verifies that a top-truncated language table carries the truncation note, also + /// alongside a caller-supplied caption, and that an untruncated one has none. + /// + [Fact] + public void BuildLanguageTable_TruncatedLanguages_ShowsNoteWithCaption() + { + var files = TestData.TwoLanguageFiles(); + var renderer = new TableRenderer(); + + string Output(AnalysisSummary summary, string? caption) + { + var console = new TestConsole(); + console.Profile.Width = 120; + console.Write(renderer.BuildLanguageTable(summary, caption, noHealth: true, noComplexity: true)); + // The caption wraps to the table's width; compare it as a single line. + return string.Join(' ', console.Output.Split((char[])[' ', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); + } + + var truncated = Output(new AnalysisSummary(files, top: 1), caption: null); + var captioned = Output(new AnalysisSummary(files, top: 1), caption: "Last update"); + var complete = Output(new AnalysisSummary(files), caption: null); + + Assert.Contains("Showing top 1 of 2 languages; totals cover all languages.", truncated); + Assert.Contains("Last update", captioned); + Assert.Contains("Showing top 1 of 2 languages", captioned); + Assert.DoesNotContain("Showing top", complete); + } + private static AnalysisSummary BuildSummary() { var file = new FileAnalysis diff --git a/tests/Sloc.Cli.Tests/TestData.cs b/tests/Sloc.Cli.Tests/TestData.cs new file mode 100644 index 0000000..48858b2 --- /dev/null +++ b/tests/Sloc.Cli.Tests/TestData.cs @@ -0,0 +1,19 @@ +using Sloc.Core.Models; + +namespace Sloc.Cli.Tests; + +/// +/// Provides analysis results shared by several renderer tests. +/// +internal static class TestData +{ + /// + /// Creates one C# file and one Python file, so a top limit of 1 leaves a language out. + /// + /// The two analyzed files. + public static FileAnalysis[] TwoLanguageFiles() => + [ + new() { Path = "a.cs", Language = "C#", Code = 3, Comment = 0, Blank = 0 }, + new() { Path = "b.py", Language = "Python", Code = 2, Comment = 0, Blank = 0 } + ]; +} diff --git a/tests/Sloc.Core.Tests/AnalysisSummarySortTests.cs b/tests/Sloc.Core.Tests/AnalysisSummarySortTests.cs index 4e9f0bc..9410c61 100644 --- a/tests/Sloc.Core.Tests/AnalysisSummarySortTests.cs +++ b/tests/Sloc.Core.Tests/AnalysisSummarySortTests.cs @@ -66,6 +66,34 @@ public void ByLanguage_TopBelowOne_IsIgnored() Assert.Equal(3, summary.ByLanguage.Count); } + /// + /// Verifies that counts every language even when + /// top trims . + /// + [Fact] + public void LanguageCount_WithTop_CoversTrimmedLanguages() + { + var summary = new AnalysisSummary(Sample(), top: 2); + + Assert.Equal(2, summary.ByLanguage.Count); + Assert.Equal(3, summary.LanguageCount); + Assert.True(summary.ByLanguageTruncated); + } + + /// + /// Verifies that the pre-aggregated constructor also reports the untrimmed language count. + /// + [Fact] + public void LanguageCount_PreAggregatedWithTop_CoversTrimmedLanguages() + { + var byLanguage = new AnalysisSummary(Sample()).ByLanguage; + + var summary = new AnalysisSummary(byLanguage, fileCount: 3, top: 1); + + Assert.Single(summary.ByLanguage); + Assert.Equal(3, summary.LanguageCount); + } + private static IReadOnlyList Sample() => [ new() { Path = "a.cs", Language = "C#", Code = 100, Comment = 5, Blank = 0 },