Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<UseArtifactsOutput>true</UseArtifactsOutput>
<LangVersion>latest</LangVersion>
<WarningsAsErrors>true</WarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<DebugType>embedded</DebugType>
</PropertyGroup>
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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` | | 显示版本 |
Expand Down
11 changes: 11 additions & 0 deletions src/Sloc.Cli/Output/ColumnLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ public static void AddOptional(List<string> row, bool noHealth, bool noComplexit
}
}

/// <summary>
/// Describes a <c>--top</c>-truncated language table, whose Total row still covers every
/// language, so a reader does not take the listed rows for the whole.
/// </summary>
/// <param name="summary">The summary whose language table is rendered.</param>
/// <returns>The note, or <see langword="null"/> when every language is listed.</returns>
public static string? TruncationNote(AnalysisSummary summary) =>
summary.ByLanguageTruncated
? $"Showing top {summary.ByLanguage.Count} of {summary.LanguageCount} languages; totals cover all languages."
: null;

/// <summary>
/// Renders a comment-health bucket as plain text, or an empty cell when the language
/// does not support comment-health classification.
Expand Down
9 changes: 8 additions & 1 deletion src/Sloc.Cli/Output/HtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -367,6 +369,11 @@ private static void BuildLanguageSection(StringBuilder sb, AnalysisSummary summa
}
sb.AppendLine("</tr></tfoot>");
sb.AppendLine("</table>");

if (ColumnLayout.TruncationNote(summary) is { } note)
{
sb.AppendLine($"<p class=\"table-note\">{Encode(note)}</p>");
}
}

private static void BuildSkippedSection(StringBuilder sb, AnalysisSummary summary)
Expand Down
10 changes: 10 additions & 0 deletions src/Sloc.Cli/Output/JsonRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -280,6 +281,15 @@ public int FileCount
get; init;
}

/// <summary>
/// The number of languages the totals cover, written only when <see cref="ByLanguage"/>
/// was cut short by <c>--top</c>.
/// </summary>
public int? LanguageCount
{
get; init;
}

public IReadOnlyList<JsonFile>? Files
{
get; init;
Expand Down
6 changes: 6 additions & 0 deletions src/Sloc.Cli/Output/MarkdownRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> cells)
Expand Down
6 changes: 4 additions & 2 deletions src/Sloc.Cli/Output/TableRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
33 changes: 33 additions & 0 deletions src/Sloc.Cli/Parsing/CliArgumentValidation.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sloc.Cli.Analysis;
using Sloc.Core.Languages;

namespace Sloc.Cli.Parsing;

Expand Down Expand Up @@ -26,6 +27,38 @@ public static bool IsValidMinCommentPct(double? value) =>
public static bool IsValidTop(int? value) =>
value is null or >= 1;

/// <summary>
/// Determines whether a <c>--format</c> value names a defined <see cref="OutputFormat"/>.
/// System.CommandLine also accepts numeric text such as <c>99</c> for an enum option, which
/// would otherwise silently fall back to Table output. A <see langword="null"/> value (the
/// option was not supplied) is always valid.
/// </summary>
/// <param name="value">The parsed <c>--format</c> value, if any.</param>
/// <returns><see langword="true"/> if the value is valid; otherwise <see langword="false"/>.</returns>
public static bool IsValidFormat(OutputFormat? value) =>
value is not { } format || Enum.IsDefined(format);

/// <summary>
/// Finds the first language name that is neither a built-in language nor <c>Other</c> (the
/// group <c>--all</c> puts unrecognized extensions in), compared case-insensitively.
/// </summary>
/// <param name="names">The values given to <c>--include-lang</c> or <c>--exclude-lang</c>.</param>
/// <returns>The first unknown name as written, or <see langword="null"/> if all are known.</returns>
public static string? FindUnknownLanguage(IEnumerable<string> 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;
}

/// <summary>
/// Validates <c>--watch</c>'s mutual exclusivity with <c>--git-hash</c>, <c>--list-file</c>,
/// non-Table formats, and <c>--baseline</c>.
Expand Down
20 changes: 17 additions & 3 deletions src/Sloc.Cli/Parsing/SlocCommandFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ public static RootCommand Create(Func<AnalyzeOptions, int> execute)

var topOption = new Option<int?>("--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<bool>("--no-update-check")
Expand Down Expand Up @@ -238,6 +238,11 @@ public static RootCommand Create(Func<AnalyzeOptions, int> 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);

Expand Down Expand Up @@ -269,6 +274,15 @@ public static RootCommand Create(Func<AnalyzeOptions, int> 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)
Expand Down Expand Up @@ -305,8 +319,8 @@ public static RootCommand Create(Func<AnalyzeOptions, int> 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),
Expand Down
2 changes: 1 addition & 1 deletion src/Sloc.Cli/Sloc.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<ToolCommandName>sloc</ToolCommandName>
<PackageId>Sloc</PackageId>
<Authors>coldhighsun</Authors>
<Description>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.</Description>
<Description>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.</Description>
<PackageTags>cloc;cli;sloc;lines-of-code;loc;statistics;tool</PackageTags>
<PackageProjectUrl>https://github.com/coldhighsun/Sloc</PackageProjectUrl>
<RepositoryUrl>https://github.com/coldhighsun/Sloc</RepositoryUrl>
Expand Down
11 changes: 11 additions & 0 deletions src/Sloc.Core/Models/AnalysisSummary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ public AnalysisSummary(
.ToList();

ByLanguage = OrderAndLimit(grouped, sortBy, descending, top);
LanguageCount = grouped.Count;
ByLanguageTruncated = ByLanguage.Count < grouped.Count;
}

Expand Down Expand Up @@ -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;
}

/// <summary>
/// The number of distinct languages the overall totals cover, including any that a
/// <c>top</c> limit left out of <see cref="ByLanguage"/>.
/// </summary>
public int LanguageCount
{
get;
}

/// <summary>
/// Whether <see cref="ByLanguage"/> was cut short by a <c>top</c> limit, so it doesn't
/// list every language the overall totals cover.
Expand Down
28 changes: 28 additions & 0 deletions tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ public void IsValidTop_RequiresAtLeastOne(int? value, bool expected)
Assert.Equal(expected, CliArgumentValidation.IsValidTop(value));
}

/// <summary>
/// Verifies that <c>--format</c> accepts a missing value and every defined format, and
/// rejects an undefined numeric value.
/// </summary>
[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));
}

/// <summary>
/// Verifies that built-in language names (any case) and <c>Other</c> are known, and that
/// the first unrecognized name is reported as written.
/// </summary>
[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));
}

/// <summary>
/// When <c>--watch</c> is not supplied, no other option combination should be rejected.
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions tests/Sloc.Cli.Tests/ColumnLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ public void AddOptional_ComplexitySuppressed_OmitsComplexityCell()
Assert.Equal(["base", "Good"], row);
}

/// <summary>
/// Verifies that the truncation note appears only when <c>top</c> left languages out, and
/// names both the listed and total language counts.
/// </summary>
[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)));
}

/// <summary>
/// Verifies that <see cref="CommentHealthLevel.NotApplicable"/> renders as an empty
/// cell, and every other level renders as its own name.
Expand Down
18 changes: 18 additions & 0 deletions tests/Sloc.Cli.Tests/CsvRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ public void Render_ByLanguage_WritesHeaderAndRows()
Assert.StartsWith("C#,1,80,20,5,105,", lines[1]);
}

/// <summary>
/// Verifies that a <c>top</c>-truncated summary renders as a plain table with no extra
/// note line, so fixed-column consumers keep parsing it.
/// </summary>
[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());
}

/// <summary>
/// Verifies that <c>noHealth</c> drops the Health column from the header and rows.
/// </summary>
Expand Down
Loading
Loading