From 90249a388ce133ffc77d970c0ef39b6aed90e80c Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 11 Sep 2026 18:32:32 +0800 Subject: [PATCH] feat: add cyclomatic complexity statistics and reorganize CLI/Core into folders Adds a simplified cyclomatic-complexity metric (branch-keyword count per file, aggregated per language) covering 13 mainstream C-style languages, exposed via --no-complexity and a Complexity column/field in all five output formats. Also splits AnalyzeHandler.cs's mixed types into Sloc.Cli/{Analysis,Parsing,Updates} and moves Sloc.Core's scanning pipeline into Sloc.Core/Scanning, matching the existing Output/-folder namespace convention. --- src/Sloc.Cli/{ => Analysis}/AnalyzeHandler.cs | 370 +----------------- src/Sloc.Cli/Analysis/AnalyzeOptions.cs | 230 +++++++++++ src/Sloc.Cli/Analysis/ExitCode.cs | 27 ++ src/Sloc.Cli/Analysis/LiveAggregator.cs | 69 ++++ src/Sloc.Cli/Analysis/OutputFormat.cs | 32 ++ src/Sloc.Cli/Output/CsvRenderer.cs | 39 +- src/Sloc.Cli/Output/HtmlRenderer.cs | 101 +++-- src/Sloc.Cli/Output/IResultRenderer.cs | 3 +- src/Sloc.Cli/Output/JsonRenderer.cs | 24 +- src/Sloc.Cli/Output/MarkdownRenderer.cs | 41 +- src/Sloc.Cli/Output/TableRenderer.cs | 197 +++++----- .../{ => Parsing}/CliArgumentValidation.cs | 8 +- src/Sloc.Cli/{ => Parsing}/FormatResolver.cs | 38 +- src/Sloc.Cli/Program.cs | 10 +- src/Sloc.Cli/{ => Updates}/UpdateChecker.cs | 4 +- src/Sloc.Core/FileAnalyzer.cs | 148 +++---- src/Sloc.Core/Languages/LanguageDefinition.cs | 44 +++ src/Sloc.Core/Languages/LanguageRegistry.cs | 61 ++- src/Sloc.Core/Models/AnalysisSummary.cs | 45 ++- src/Sloc.Core/Models/FileAnalysis.cs | 10 + .../{ => Scanning}/DirectoryScanner.cs | 2 +- .../{ => Scanning}/GitAttributesRules.cs | 2 +- .../{ => Scanning}/GitIgnoreRules.cs | 2 +- .../{ => Scanning}/RelativePathResolver.cs | 2 +- .../{ => Scanning}/ScanTreeWalker.cs | 2 +- src/Sloc.Core/{ => Scanning}/SymlinkGuard.cs | 2 +- .../AnalyzeHandlerGitHashTests.cs | 1 + tests/Sloc.Cli.Tests/AnalyzeHandlerTests.cs | 1 + .../CliArgumentValidationTests.cs | 2 + tests/Sloc.Cli.Tests/CsvRendererTests.cs | 50 ++- tests/Sloc.Cli.Tests/FormatResolverTests.cs | 3 + tests/Sloc.Cli.Tests/MarkdownRendererTests.cs | 42 +- tests/Sloc.Cli.Tests/TableRendererTests.cs | 40 +- tests/Sloc.Cli.Tests/UpdateCheckerTests.cs | 1 + .../Sloc.Core.Tests/DirectoryScannerTests.cs | 2 + .../FileAnalyzerComplexityTests.cs | 80 ++++ .../GitAttributesRulesTests.cs | 2 + tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs | 2 + .../LanguageRegistryComplexityTests.cs | 64 +++ 39 files changed, 1181 insertions(+), 622 deletions(-) rename src/Sloc.Cli/{ => Analysis}/AnalyzeHandler.cs (71%) create mode 100644 src/Sloc.Cli/Analysis/AnalyzeOptions.cs create mode 100644 src/Sloc.Cli/Analysis/ExitCode.cs create mode 100644 src/Sloc.Cli/Analysis/LiveAggregator.cs create mode 100644 src/Sloc.Cli/Analysis/OutputFormat.cs rename src/Sloc.Cli/{ => Parsing}/CliArgumentValidation.cs (90%) rename src/Sloc.Cli/{ => Parsing}/FormatResolver.cs (97%) rename src/Sloc.Cli/{ => Updates}/UpdateChecker.cs (99%) rename src/Sloc.Core/{ => Scanning}/DirectoryScanner.cs (99%) rename src/Sloc.Core/{ => Scanning}/GitAttributesRules.cs (99%) rename src/Sloc.Core/{ => Scanning}/GitIgnoreRules.cs (99%) rename src/Sloc.Core/{ => Scanning}/RelativePathResolver.cs (98%) rename src/Sloc.Core/{ => Scanning}/ScanTreeWalker.cs (99%) rename src/Sloc.Core/{ => Scanning}/SymlinkGuard.cs (99%) create mode 100644 tests/Sloc.Core.Tests/FileAnalyzerComplexityTests.cs create mode 100644 tests/Sloc.Core.Tests/LanguageRegistryComplexityTests.cs diff --git a/src/Sloc.Cli/AnalyzeHandler.cs b/src/Sloc.Cli/Analysis/AnalyzeHandler.cs similarity index 71% rename from src/Sloc.Cli/AnalyzeHandler.cs rename to src/Sloc.Cli/Analysis/AnalyzeHandler.cs index cb80803..c9a2b86 100644 --- a/src/Sloc.Cli/AnalyzeHandler.cs +++ b/src/Sloc.Cli/Analysis/AnalyzeHandler.cs @@ -1,287 +1,13 @@ using Sloc.Cli.Output; +using Sloc.Cli.Updates; using Sloc.Core; using Sloc.Core.Models; +using Sloc.Core.Scanning; using Spectre.Console; using System.Diagnostics; using System.Reflection; -namespace Sloc.Cli; - -/// -/// The supported output formats. -/// -public enum OutputFormat -{ - /// - /// A human-readable, colored table. - /// - Table, - - /// - /// Machine-readable JSON. - /// - Json, - - /// - /// A human-readable HTML report. - /// - Html, - - /// - /// Comma-separated values (spreadsheet-friendly). - /// - Csv, - - /// - /// GitHub-Flavored Markdown tables (documentation-friendly). - /// - Markdown -} - -/// -/// Process exit codes returned by the CLI. -/// -public static class ExitCode -{ - /// - /// The requested path was not found or could not be read. - /// - public const int Error = 1; - - /// - /// The run completed successfully. - /// - public const int Success = 0; - - /// - /// A configured threshold (e.g. --min-comment-pct) was not met. - /// - public const int ThresholdNotMet = 2; - - /// - /// An unexpected error occurred. - /// - public const int Unexpected = 3; -} - -/// -/// The parsed options for an analysis run. -/// -public sealed class AnalyzeOptions -{ - /// - /// When set, a previously saved JSON report to compare the current run against; the - /// output becomes a diff of line counts rather than the normal report. - /// - public string? BaselinePath - { - get; init; - } - - /// - /// When , a per-file breakdown is shown. - /// - public bool ByFile - { - get; init; - } - - /// - /// When , JSON and HTML output includes both the by-language - /// summary and the per-file breakdown together (only meaningful for those formats). - /// - public bool Detailed - { - get; init; - } - - /// - /// Language display names to exclude (e.g. "Markdown"). - /// - public IReadOnlyList ExcludeLangs { get; init; } = []; - - /// - /// Glob patterns of files to exclude. - /// - public IReadOnlyList Excludes { get; init; } = []; - - /// - /// Whether to descend into symlinked/junctioned directories rather than skip them. - /// Defaults to . - /// - public bool FollowSymlinks - { - get; init; - } - - /// - /// The output format. - /// - public OutputFormat Format - { - get; init; - } - - /// - /// When set, a commit/tree-ish to analyze the repository tree of as it existed at - /// that commit, without checking it out. is used as the repo root - /// to query. Mutually exclusive with . - /// - public string? GitHash - { - get; init; - } - - /// - /// Language display names to include (e.g. "C#"). When empty, all languages - /// are considered. - /// - public IReadOnlyList IncludeLangs { get; init; } = []; - - /// - /// Glob patterns of files to include. - /// - public IReadOnlyList Includes { get; init; } = []; - - /// - /// When , files with unknown extensions are included. - /// - public bool IncludeUnknown - { - get; init; - } - - /// - /// The maximum number of files to analyze in parallel. When - /// or non-positive, is used. Set to 1 for - /// fully sequential analysis. - /// - public int? Jobs - { - get; init; - } - - /// - /// When set, a file listing paths (one per line) to analyze directly instead of - /// scanning . - reads the list from stdin. - /// - public string? ListFile - { - get; init; - } - - /// - /// When set, the run fails (returns ) if the - /// overall comment percentage (comment lines / total lines) is below this value. - /// - public double? MinCommentPct - { - get; init; - } - - /// - /// When , the Comment Health column and percentage - /// breakdowns are hidden. - /// - public bool NoHealth - { - get; init; - } - - /// - /// When , suppresses the live table and progress bar while - /// still printing the banner and result. - /// - public bool NoProgress - { - get; init; - } - - /// - /// When , subdirectories are not scanned. - /// - public bool NoRecursive - { - get; init; - } - - /// - /// When , skips the GitHub check for a newer release. - /// - public bool NoUpdateCheck - { - get; init; - } - - /// - /// The output file path for Json and Html formats. - /// When , a default name is used. - /// - public string? OutputFile - { - get; init; - } - - /// - /// When , the per-file console output is paginated. - /// - public bool Paged - { - get; init; - } - - /// - /// The file or directory to analyze. - /// - public required string Path - { - get; init; - } - - /// - /// When , suppresses the version banner, progress UI, and the - /// "Saved to" message, leaving only the result output. - /// - public bool Quiet - { - get; init; - } - - /// - /// Whether to exclude files marked linguist-vendored or linguist-generated - /// in .gitattributes files discovered under the scan root. Defaults to - /// . - /// - public bool RespectGitAttributes { get; init; } = true; - - /// - /// Whether to honor .gitignore files discovered under the scan root. - /// Defaults to . - /// - public bool RespectGitignore { get; init; } = true; - - /// - /// The key by which the per-language summary is ordered. - /// - public LanguageSort Sort { get; init; } = LanguageSort.Total; - - /// - /// When set, keeps only the first this-many languages in the summary after sorting. - /// - public int? Top - { - get; init; - } - - /// - /// When , files with identical content are only counted once; - /// later duplicates are reported as skipped rather than double-counted. - /// - public bool Unique - { - get; init; - } -} +namespace Sloc.Cli.Analysis; /// /// Orchestrates an analysis run: scan files, analyze each one, aggregate the @@ -485,7 +211,7 @@ void AnalyzeAt(int i) } else if (options is { Format: OutputFormat.Table, ByFile: false, BaselinePath: null }) { - AnsiConsole.Live(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth)) + AnsiConsole.Live(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth, noComplexity: options.NoComplexity)) .AutoClear(false) .Start(ctx => { @@ -497,12 +223,13 @@ void AnalyzeAt(int i) ctx.UpdateTarget(tableRenderer.BuildLanguageTable( aggregator.ToSummary(), $"[grey]Analyzing... {aggregator.FilesProcessed:N0} / {files.Count:N0}[/]", - noHealth: options.NoHealth)); + noHealth: options.NoHealth, + noComplexity: options.NoComplexity)); Thread.Sleep(LiveTableRefreshInterval); } work.GetAwaiter().GetResult(); - ctx.UpdateTarget(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth)); + ctx.UpdateTarget(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth, noComplexity: options.NoComplexity)); }); } else @@ -614,11 +341,11 @@ void AnalyzeAt(int i) // JSON defaults to stdout (pipeable); an explicit path writes a file. if (options.OutputFile is null || options.OutputFile == StdoutToken) { - new JsonRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath); + new JsonRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity); } else { - if (!WriteToFile(options.OutputFile, writer => new JsonRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + if (!WriteToFile(options.OutputFile, writer => new JsonRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity), options.Quiet)) { return ExitCode.Error; } @@ -629,11 +356,11 @@ void AnalyzeAt(int i) // Html defaults to stdout (pipeable); an explicit path writes a file. if (options.OutputFile is null || options.OutputFile == StdoutToken) { - new HtmlRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath); + new HtmlRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity); } else { - if (!WriteToFile(options.OutputFile, writer => new HtmlRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + if (!WriteToFile(options.OutputFile, writer => new HtmlRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity), options.Quiet)) { return ExitCode.Error; } @@ -644,11 +371,11 @@ void AnalyzeAt(int i) // CSV defaults to stdout (pipeable); an explicit path writes a file. if (options.OutputFile is null || options.OutputFile == StdoutToken) { - new CsvRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath); + new CsvRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity); } else { - if (!WriteToFile(options.OutputFile, writer => new CsvRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + if (!WriteToFile(options.OutputFile, writer => new CsvRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity), options.Quiet)) { return ExitCode.Error; } @@ -659,11 +386,11 @@ void AnalyzeAt(int i) // Markdown defaults to stdout (pasteable); an explicit path writes a file. if (options.OutputFile is null || options.OutputFile == StdoutToken) { - new MarkdownRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath); + new MarkdownRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity); } else { - if (!WriteToFile(options.OutputFile, writer => new MarkdownRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + if (!WriteToFile(options.OutputFile, writer => new MarkdownRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath, options.NoComplexity), options.Quiet)) { return ExitCode.Error; } @@ -677,12 +404,12 @@ void AnalyzeAt(int i) } else if (options.ByFile) { - tableRenderer.RenderByFile(summary, options.NoHealth, options.Paged); + tableRenderer.RenderByFile(summary, options.NoHealth, options.Paged, options.NoComplexity); } else if (!showProgress) { // The live table only renders during progress; render it here otherwise. - AnsiConsole.Write(tableRenderer.BuildLanguageTable(summary, noHealth: options.NoHealth)); + AnsiConsole.Write(tableRenderer.BuildLanguageTable(summary, noHealth: options.NoHealth, noComplexity: options.NoComplexity)); } tableRenderer.RenderSkipped(summary); @@ -761,6 +488,7 @@ private static List RemapGitPaths(List analyses, Dic Code = analysis.Code, Comment = analysis.Comment, Blank = analysis.Blank, + Complexity = analysis.Complexity, Hash = analysis.Hash }; } @@ -871,66 +599,4 @@ private static bool WriteToFile(string path, Action render, bool qui return true; } - - /// - /// Thread-safe incremental aggregator of per-language counts, used to refresh the - /// live table without re-aggregating every analyzed file on each tick. - /// - private sealed class LiveAggregator(LanguageSort sortBy, int? top) - { - private readonly Dictionary _byLanguage = new(StringComparer.OrdinalIgnoreCase); - private readonly object _gate = new(); - private int _files; - - public int FilesProcessed - { - get - { - lock (_gate) - { - return _files; - } - } - } - - public void Add(FileAnalysis analysis) - { - lock (_gate) - { - _files++; - _byLanguage.TryGetValue(analysis.Language, out var counts); - _byLanguage[analysis.Language] = new Counts( - counts.Files + 1, - counts.Code + analysis.Code, - counts.Comment + analysis.Comment, - counts.Blank + analysis.Blank); - } - } - - public AnalysisSummary ToSummary() - { - lock (_gate) - { - var byLanguage = _byLanguage - .Select(entry => new LanguageStatistics - { - Language = entry.Key, - Files = entry.Value.Files, - Code = entry.Value.Code, - Comment = entry.Value.Comment, - Blank = entry.Value.Blank - }); - - var ordered = AnalysisSummary.OrderAndLimit( - byLanguage, - sortBy, - descending: sortBy != LanguageSort.Name, - top); - - return new AnalysisSummary(ordered, _files); - } - } - - private readonly record struct Counts(int Files, int Code, int Comment, int Blank); - } } \ No newline at end of file diff --git a/src/Sloc.Cli/Analysis/AnalyzeOptions.cs b/src/Sloc.Cli/Analysis/AnalyzeOptions.cs new file mode 100644 index 0000000..8c9a5d8 --- /dev/null +++ b/src/Sloc.Cli/Analysis/AnalyzeOptions.cs @@ -0,0 +1,230 @@ +using Sloc.Core.Models; + +namespace Sloc.Cli.Analysis; + +/// +/// The parsed options for an analysis run. +/// +public sealed class AnalyzeOptions +{ + /// + /// When set, a previously saved JSON report to compare the current run against; the + /// output becomes a diff of line counts rather than the normal report. + /// + public string? BaselinePath + { + get; init; + } + + /// + /// When , a per-file breakdown is shown. + /// + public bool ByFile + { + get; init; + } + + /// + /// When , JSON and HTML output includes both the by-language + /// summary and the per-file breakdown together (only meaningful for those formats). + /// + public bool Detailed + { + get; init; + } + + /// + /// Language display names to exclude (e.g. "Markdown"). + /// + public IReadOnlyList ExcludeLangs { get; init; } = []; + + /// + /// Glob patterns of files to exclude. + /// + public IReadOnlyList Excludes { get; init; } = []; + + /// + /// Whether to descend into symlinked/junctioned directories rather than skip them. + /// Defaults to . + /// + public bool FollowSymlinks + { + get; init; + } + + /// + /// The output format. + /// + public OutputFormat Format + { + get; init; + } + + /// + /// When set, a commit/tree-ish to analyze the repository tree of as it existed at + /// that commit, without checking it out. is used as the repo root + /// to query. Mutually exclusive with . + /// + public string? GitHash + { + get; init; + } + + /// + /// Language display names to include (e.g. "C#"). When empty, all languages + /// are considered. + /// + public IReadOnlyList IncludeLangs { get; init; } = []; + + /// + /// Glob patterns of files to include. + /// + public IReadOnlyList Includes { get; init; } = []; + + /// + /// When , files with unknown extensions are included. + /// + public bool IncludeUnknown + { + get; init; + } + + /// + /// The maximum number of files to analyze in parallel. When + /// or non-positive, is used. Set to 1 for + /// fully sequential analysis. + /// + public int? Jobs + { + get; init; + } + + /// + /// When set, a file listing paths (one per line) to analyze directly instead of + /// scanning . - reads the list from stdin. + /// + public string? ListFile + { + get; init; + } + + /// + /// When set, the run fails (returns ) if the + /// overall comment percentage (comment lines / total lines) is below this value. + /// + public double? MinCommentPct + { + get; init; + } + + /// + /// When , the Complexity column/field is hidden. + /// + public bool NoComplexity + { + get; init; + } + + /// + /// When , the Comment Health column and percentage + /// breakdowns are hidden. + /// + public bool NoHealth + { + get; init; + } + + /// + /// When , suppresses the live table and progress bar while + /// still printing the banner and result. + /// + public bool NoProgress + { + get; init; + } + + /// + /// When , subdirectories are not scanned. + /// + public bool NoRecursive + { + get; init; + } + + /// + /// When , skips the GitHub check for a newer release. + /// + public bool NoUpdateCheck + { + get; init; + } + + /// + /// The output file path for Json and Html formats. + /// When , a default name is used. + /// + public string? OutputFile + { + get; init; + } + + /// + /// When , the per-file console output is paginated. + /// + public bool Paged + { + get; init; + } + + /// + /// The file or directory to analyze. + /// + public required string Path + { + get; init; + } + + /// + /// When , suppresses the version banner, progress UI, and the + /// "Saved to" message, leaving only the result output. + /// + public bool Quiet + { + get; init; + } + + /// + /// Whether to exclude files marked linguist-vendored or linguist-generated + /// in .gitattributes files discovered under the scan root. Defaults to + /// . + /// + public bool RespectGitAttributes { get; init; } = true; + + /// + /// Whether to honor .gitignore files discovered under the scan root. + /// Defaults to . + /// + public bool RespectGitignore { get; init; } = true; + + /// + /// The key by which the per-language summary is ordered. + /// + public LanguageSort Sort { get; init; } = LanguageSort.Total; + + /// + /// When set, keeps only the first this-many languages in the summary after sorting. + /// + public int? Top + { + get; init; + } + + /// + /// When , files with identical content are only counted once; + /// later duplicates are reported as skipped rather than double-counted. + /// + public bool Unique + { + get; init; + } +} \ No newline at end of file diff --git a/src/Sloc.Cli/Analysis/ExitCode.cs b/src/Sloc.Cli/Analysis/ExitCode.cs new file mode 100644 index 0000000..ac1796f --- /dev/null +++ b/src/Sloc.Cli/Analysis/ExitCode.cs @@ -0,0 +1,27 @@ +namespace Sloc.Cli.Analysis; + +/// +/// Process exit codes returned by the CLI. +/// +public static class ExitCode +{ + /// + /// The requested path was not found or could not be read. + /// + public const int Error = 1; + + /// + /// The run completed successfully. + /// + public const int Success = 0; + + /// + /// A configured threshold (e.g. --min-comment-pct) was not met. + /// + public const int ThresholdNotMet = 2; + + /// + /// An unexpected error occurred. + /// + public const int Unexpected = 3; +} \ No newline at end of file diff --git a/src/Sloc.Cli/Analysis/LiveAggregator.cs b/src/Sloc.Cli/Analysis/LiveAggregator.cs new file mode 100644 index 0000000..943a339 --- /dev/null +++ b/src/Sloc.Cli/Analysis/LiveAggregator.cs @@ -0,0 +1,69 @@ +using Sloc.Core.Models; + +namespace Sloc.Cli.Analysis; + +/// +/// Thread-safe incremental aggregator of per-language counts, used by +/// to refresh the live table without re-aggregating every +/// analyzed file on each tick. +/// +internal sealed class LiveAggregator(LanguageSort sortBy, int? top) +{ + private readonly Dictionary _byLanguage = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gate = new(); + private int _files; + + public int FilesProcessed + { + get + { + lock (_gate) + { + return _files; + } + } + } + + public void Add(FileAnalysis analysis) + { + lock (_gate) + { + _files++; + _byLanguage.TryGetValue(analysis.Language, out var counts); + _byLanguage[analysis.Language] = new Counts( + counts.Files + 1, + counts.Code + analysis.Code, + counts.Comment + analysis.Comment, + counts.Blank + analysis.Blank, + analysis.Complexity is { } complexity ? counts.ComplexityTotal + complexity : counts.ComplexityTotal, + counts.HasComplexitySupport || analysis.Complexity.HasValue); + } + } + + public AnalysisSummary ToSummary() + { + lock (_gate) + { + var byLanguage = _byLanguage + .Select(entry => new LanguageStatistics + { + Language = entry.Key, + Files = entry.Value.Files, + Code = entry.Value.Code, + Comment = entry.Value.Comment, + Blank = entry.Value.Blank, + ComplexityTotal = entry.Value.HasComplexitySupport ? entry.Value.ComplexityTotal : null + }); + + var ordered = AnalysisSummary.OrderAndLimit( + byLanguage, + sortBy, + descending: sortBy != LanguageSort.Name, + top); + + return new AnalysisSummary(ordered, _files); + } + } + + private readonly record struct Counts(int Files, int Code, int Comment, int Blank, int ComplexityTotal = 0, bool HasComplexitySupport = false); +} \ No newline at end of file diff --git a/src/Sloc.Cli/Analysis/OutputFormat.cs b/src/Sloc.Cli/Analysis/OutputFormat.cs new file mode 100644 index 0000000..d1ce5fc --- /dev/null +++ b/src/Sloc.Cli/Analysis/OutputFormat.cs @@ -0,0 +1,32 @@ +namespace Sloc.Cli.Analysis; + +/// +/// The supported output formats. +/// +public enum OutputFormat +{ + /// + /// A human-readable, colored table. + /// + Table, + + /// + /// Machine-readable JSON. + /// + Json, + + /// + /// A human-readable HTML report. + /// + Html, + + /// + /// Comma-separated values (spreadsheet-friendly). + /// + Csv, + + /// + /// GitHub-Flavored Markdown tables (documentation-friendly). + /// + Markdown +} \ No newline at end of file diff --git a/src/Sloc.Cli/Output/CsvRenderer.cs b/src/Sloc.Cli/Output/CsvRenderer.cs index 904de8c..a3ef439 100644 --- a/src/Sloc.Cli/Output/CsvRenderer.cs +++ b/src/Sloc.Cli/Output/CsvRenderer.cs @@ -36,7 +36,7 @@ public CsvRenderer(TextWriter? writer = null) /// and is intentionally ignored, for the same reason this renderer has no /// report-generation-time field (see the class remarks). /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null, bool noComplexity = false) { ArgumentNullException.ThrowIfNull(summary); @@ -44,11 +44,11 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det // single well-formed table), so --by-file alone selects the per-file view. if (byFile && !detailed) { - RenderByFile(summary, noHealth); + RenderByFile(summary, noHealth, noComplexity); } else { - RenderByLanguage(summary, noHealth); + RenderByLanguage(summary, noHealth, noComplexity); } if (summary.Skipped.Count > 0) @@ -57,6 +57,9 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det } } + private static string ComplexityCell(int? complexity) => + complexity?.ToString() ?? string.Empty; + private static string Escape(string field) { if (field.IndexOfAny([',', '"', '\r', '\n']) < 0) @@ -70,13 +73,17 @@ private static string Escape(string field) private static string HealthCell(CommentHealthLevel health) => health == CommentHealthLevel.NotApplicable ? string.Empty : health.ToString(); - private void RenderByFile(AnalysisSummary summary, bool noHealth) + private void RenderByFile(AnalysisSummary summary, bool noHealth, bool noComplexity) { var header = new List { "Path", "Language", "Code", "Comment", "Blank", "Total" }; if (!noHealth) { header.Add("Health"); } + if (!noComplexity) + { + header.Add("Complexity"); + } WriteRow(header); @@ -95,20 +102,28 @@ private void RenderByFile(AnalysisSummary summary, bool noHealth) { row.Add(HealthCell(file.Health)); } + if (!noComplexity) + { + row.Add(ComplexityCell(file.Complexity)); + } WriteRow(row); } - WriteTotalRow(summary, noHealth, string.Empty); + WriteTotalRow(summary, noHealth, noComplexity, string.Empty); } - private void RenderByLanguage(AnalysisSummary summary, bool noHealth) + private void RenderByLanguage(AnalysisSummary summary, bool noHealth, bool noComplexity) { var header = new List { "Language", "Files", "Code", "Comment", "Blank", "Total" }; if (!noHealth) { header.Add("Health"); } + if (!noComplexity) + { + header.Add("Complexity"); + } WriteRow(header); @@ -127,11 +142,15 @@ private void RenderByLanguage(AnalysisSummary summary, bool noHealth) { row.Add(HealthCell(language.Health)); } + if (!noComplexity) + { + row.Add(ComplexityCell(language.ComplexityTotal)); + } WriteRow(row); } - WriteTotalRow(summary, noHealth, summary.FileCount.ToString()); + WriteTotalRow(summary, noHealth, noComplexity, summary.FileCount.ToString()); } private void RenderSkipped(AnalysisSummary summary) @@ -165,7 +184,7 @@ private void WriteRow(IReadOnlyList fields) // The second column is the file/language count for the by-language table and blank for // the by-file table; every numeric column carries the run-wide total. - private void WriteTotalRow(AnalysisSummary summary, bool noHealth, string secondColumn) + private void WriteTotalRow(AnalysisSummary summary, bool noHealth, bool noComplexity, string secondColumn) { var row = new List { @@ -180,6 +199,10 @@ private void WriteTotalRow(AnalysisSummary summary, bool noHealth, string second { row.Add(string.Empty); } + if (!noComplexity) + { + row.Add(ComplexityCell(summary.ComplexityTotal)); + } WriteRow(row); } diff --git a/src/Sloc.Cli/Output/HtmlRenderer.cs b/src/Sloc.Cli/Output/HtmlRenderer.cs index b04a7c8..bda1e80 100644 --- a/src/Sloc.Cli/Output/HtmlRenderer.cs +++ b/src/Sloc.Cli/Output/HtmlRenderer.cs @@ -206,16 +206,16 @@ public HtmlRenderer(TextWriter? writer = null, DateTimeOffset? generatedAt = nul } /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null, bool noComplexity = false) { ArgumentNullException.ThrowIfNull(summary); var sb = new StringBuilder(); - BuildDocument(sb, summary, byFile, noHealth, detailed, sourcePath); + BuildDocument(sb, summary, byFile, noHealth, detailed, sourcePath, noComplexity); _writer.Write(sb); } - private static void BuildFileSection(StringBuilder sb, AnalysisSummary summary, bool noHealth) + private static void BuildFileSection(StringBuilder sb, AnalysisSummary summary, bool noHealth, bool noComplexity) { sb.AppendLine($"

{Encode("By File")}

"); sb.AppendLine("
"); @@ -224,7 +224,8 @@ private static void BuildFileSection(StringBuilder sb, AnalysisSummary summary, sb.AppendLine("
"); sb.AppendLine(""); var healthTh = noHealth ? string.Empty : $""; - sb.AppendLine($" {healthTh}"); + var complexityTh = noComplexity ? string.Empty : $""; + sb.AppendLine($" {healthTh}{complexityTh}"); sb.AppendLine(" "); var root = BuildFolderTree(summary.Files); @@ -235,30 +236,30 @@ private static void BuildFileSection(StringBuilder sb, AnalysisSummary summary, if (root.Name.Length > 0) { - RenderFolderRecursive(sb, root, null, 0, ref folderIndex, noHealth); + RenderFolderRecursive(sb, root, null, 0, ref folderIndex, noHealth, noComplexity); } else if (root.Files.Count > 0) { var rootId = $"folder-{folderIndex}"; folderIndex++; - RenderFolderRow(sb, rootId, null, "(root)", root, 0, noHealth); + RenderFolderRow(sb, rootId, null, "(root)", root, 0, noHealth, noComplexity); foreach (var file in root.Files.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) { - RenderFileRow(sb, rootId, file, 1, noHealth); + RenderFileRow(sb, rootId, file, 1, noHealth, noComplexity); } foreach (var child in root.Children.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) { - RenderFolderRecursive(sb, child, rootId, 1, ref folderIndex, noHealth); + RenderFolderRecursive(sb, child, rootId, 1, ref folderIndex, noHealth, noComplexity); } } else { foreach (var child in root.Children.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) { - RenderFolderRecursive(sb, child, null, 0, ref folderIndex, noHealth); + RenderFolderRecursive(sb, child, null, 0, ref folderIndex, noHealth, noComplexity); } } @@ -270,14 +271,15 @@ private static void BuildFileSection(StringBuilder sb, AnalysisSummary summary, sb.Append($""); sb.Append($""); sb.Append($""); - if (noHealth) + if (!noHealth) { - sb.AppendLine(""); + sb.Append(""); } - else + if (!noComplexity) { - sb.AppendLine(""); + sb.Append($""); } + sb.AppendLine(""); sb.AppendLine("
{Encode("Comment Health")}
{Encode("File")}{Encode("Language")}{Encode("Code")}{Encode("Comment")}{Encode("Blank")}{Encode("Total")}
{Encode("Complexity")}
{Encode("File")}{Encode("Language")}{Encode("Code")}{Encode("Comment")}{Encode("Blank")}{Encode("Total")}
{NumCell(summary.Comment, summary.Total, noHealth)}{NumCell(summary.Blank, summary.Total, noHealth)}{summary.Total:N0}
{ComplexityCell(summary.ComplexityTotal)}
"); } @@ -318,12 +320,13 @@ private static FolderNode BuildFolderTree(IReadOnlyList files) return root; } - private static void BuildLanguageSection(StringBuilder sb, AnalysisSummary summary, bool noHealth) + private static void BuildLanguageSection(StringBuilder sb, AnalysisSummary summary, bool noHealth, bool noComplexity) { sb.AppendLine($"

{Encode("By Language")}

"); sb.AppendLine(""); var healthTh = noHealth ? string.Empty : $""; - sb.AppendLine($" {healthTh}"); + var complexityTh = noComplexity ? string.Empty : $""; + sb.AppendLine($" {healthTh}{complexityTh}"); sb.AppendLine(" "); foreach (var lang in summary.ByLanguage) @@ -339,6 +342,10 @@ private static void BuildLanguageSection(StringBuilder sb, AnalysisSummary summa { sb.Append($""); } + if (!noComplexity) + { + sb.Append($""); + } sb.AppendLine(""); } @@ -350,14 +357,15 @@ private static void BuildLanguageSection(StringBuilder sb, AnalysisSummary summa sb.Append($""); sb.Append($""); sb.Append($""); - if (noHealth) + if (!noHealth) { - sb.AppendLine(""); + sb.Append(""); } - else + if (!noComplexity) { - sb.AppendLine(""); + sb.Append($""); } + sb.AppendLine(""); sb.AppendLine("
{Encode("Comment Health")}
{Encode("Language")}{Encode("Files")}{Encode("Code")}{Encode("Comment")}{Encode("Blank")}{Encode("Total")}
{Encode("Complexity")}
{Encode("Language")}{Encode("Files")}{Encode("Code")}{Encode("Comment")}{Encode("Blank")}{Encode("Total")}
{HealthCell(lang.Health)}{ComplexityCell(lang.ComplexityTotal)}
{NumCell(summary.Comment, summary.Total, noHealth)}{NumCell(summary.Blank, summary.Total, noHealth)}{summary.Total:N0}
{ComplexityCell(summary.ComplexityTotal)}
"); } @@ -378,6 +386,9 @@ private static void BuildSkippedSection(StringBuilder sb, AnalysisSummary summar sb.AppendLine(""); } + private static string ComplexityCell(int? complexity) => + complexity is { } value ? value.ToString("N0") : ""; + private static void ComputeTotals(FolderNode node) { var code = 0; @@ -389,6 +400,9 @@ private static void ComputeTotals(FolderNode node) var healthComment = 0; var hasHealthSupport = false; + var complexityTotal = 0; + var hasComplexitySupport = false; + foreach (var file in node.Files) { code += file.File.Code; @@ -402,6 +416,12 @@ private static void ComputeTotals(FolderNode node) healthComment += file.File.Comment; hasHealthSupport = true; } + + if (file.File.Complexity is { } fileComplexity) + { + complexityTotal += fileComplexity; + hasComplexitySupport = true; + } } foreach (var child in node.Children.Values) @@ -416,6 +436,9 @@ private static void ComputeTotals(FolderNode node) healthCode += child.HealthCode; healthComment += child.HealthComment; hasHealthSupport = hasHealthSupport || child.HasHealthSupport; + + complexityTotal += child.ComplexityTotal ?? 0; + hasComplexitySupport = hasComplexitySupport || child.HasComplexitySupport; } node.Code = code; @@ -425,6 +448,8 @@ private static void ComputeTotals(FolderNode node) node.HealthCode = healthCode; node.HealthComment = healthComment; node.HasHealthSupport = hasHealthSupport; + node.HasComplexitySupport = hasComplexitySupport; + node.ComplexityTotal = hasComplexitySupport ? complexityTotal : null; } private static string Encode(string value) => WebUtility.HtmlEncode(value); @@ -465,7 +490,7 @@ private static string NumCell(int count, int total, bool noHealth = false) return $"{count:N0}({pct:F0}%)"; } - private static void RenderFileRow(StringBuilder sb, string parentId, FileEntry entry, int depth, bool noHealth) + private static void RenderFileRow(StringBuilder sb, string parentId, FileEntry entry, int depth, bool noHealth, bool noComplexity) { var indent = 36 + depth * 18; @@ -480,28 +505,32 @@ private static void RenderFileRow(StringBuilder sb, string parentId, FileEntry e { sb.Append($"{HealthCell(entry.File.Health)}"); } + if (!noComplexity) + { + sb.Append($"{ComplexityCell(entry.File.Complexity)}"); + } sb.AppendLine(""); } - private static void RenderFolderRecursive(StringBuilder sb, FolderNode node, string? parentId, int depth, ref int folderIndex, bool noHealth) + private static void RenderFolderRecursive(StringBuilder sb, FolderNode node, string? parentId, int depth, ref int folderIndex, bool noHealth, bool noComplexity) { var nodeId = $"folder-{folderIndex}"; folderIndex++; - RenderFolderRow(sb, nodeId, parentId, node.Name, node, depth, noHealth); + RenderFolderRow(sb, nodeId, parentId, node.Name, node, depth, noHealth, noComplexity); foreach (var file in node.Files.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) { - RenderFileRow(sb, nodeId, file, depth + 1, noHealth); + RenderFileRow(sb, nodeId, file, depth + 1, noHealth, noComplexity); } foreach (var child in node.Children.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) { - RenderFolderRecursive(sb, child, nodeId, depth + 1, ref folderIndex, noHealth); + RenderFolderRecursive(sb, child, nodeId, depth + 1, ref folderIndex, noHealth, noComplexity); } } - private static void RenderFolderRow(StringBuilder sb, string nodeId, string? parentId, string label, FolderNode node, int depth, bool noHealth) + private static void RenderFolderRow(StringBuilder sb, string nodeId, string? parentId, string label, FolderNode node, int depth, bool noHealth, bool noComplexity) { var indent = 8 + depth * 18; var parentAttr = parentId is null ? string.Empty : $" data-parent-id=\"{parentId}\""; @@ -524,6 +553,10 @@ private static void RenderFolderRow(StringBuilder sb, string nodeId, string? par { sb.Append($"{HealthCell(CommentHealth.Classify(node.HasHealthSupport, node.HealthCode, node.HealthComment))}"); } + if (!noComplexity) + { + sb.Append($"{ComplexityCell(node.ComplexityTotal)}"); + } sb.AppendLine(""); } @@ -558,7 +591,7 @@ private static string ToRelative(string path) } } - private void BuildDocument(StringBuilder sb, AnalysisSummary summary, bool byFile, bool noHealth, bool detailed, string? sourcePath) + private void BuildDocument(StringBuilder sb, AnalysisSummary summary, bool byFile, bool noHealth, bool detailed, string? sourcePath, bool noComplexity) { sb.AppendLine(""); sb.AppendLine(""); @@ -578,12 +611,12 @@ private void BuildDocument(StringBuilder sb, AnalysisSummary summary, bool byFil if (detailed || !byFile) { - BuildLanguageSection(sb, summary, noHealth); + BuildLanguageSection(sb, summary, noHealth, noComplexity); } if (detailed || byFile) { - BuildFileSection(sb, summary, noHealth); + BuildFileSection(sb, summary, noHealth, noComplexity); } if (summary.Skipped.Count > 0) @@ -639,6 +672,12 @@ public int Comment set; } + public int? ComplexityTotal + { + get; + set; + } + public int FileCount { get; @@ -650,6 +689,12 @@ public List Files get; } = []; + public bool HasComplexitySupport + { + get; + set; + } + public bool HasHealthSupport { get; diff --git a/src/Sloc.Cli/Output/IResultRenderer.cs b/src/Sloc.Cli/Output/IResultRenderer.cs index 0d16597..a524a36 100644 --- a/src/Sloc.Cli/Output/IResultRenderer.cs +++ b/src/Sloc.Cli/Output/IResultRenderer.cs @@ -22,5 +22,6 @@ public interface IResultRenderer /// metadata (where supported) so a saved or shared report can be traced back to its /// source. omits it. /// - void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null); + /// When , the Complexity column/field is hidden. + void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null, bool noComplexity = false); } \ No newline at end of file diff --git a/src/Sloc.Cli/Output/JsonRenderer.cs b/src/Sloc.Cli/Output/JsonRenderer.cs index 11b4f5f..12b7d23 100644 --- a/src/Sloc.Cli/Output/JsonRenderer.cs +++ b/src/Sloc.Cli/Output/JsonRenderer.cs @@ -30,7 +30,7 @@ public JsonRenderer(TextWriter? writer = null, DateTimeOffset? generatedAt = nul } /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null, bool noComplexity = false) { ArgumentNullException.ThrowIfNull(summary); @@ -49,6 +49,7 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det Blank = summary.Blank, BlankPct = Pct(summary.Blank, summary.Total), Total = summary.Total, + Complexity = noComplexity ? null : summary.ComplexityTotal, ByLanguage = !includeLanguages ? null : summary.ByLanguage.Select(language => new JsonLanguage { Language = language.Language, @@ -60,7 +61,8 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det Blank = language.Blank, BlankPct = Pct(language.Blank, language.Total), Total = language.Total, - Health = Health(language.Health, noHealth) + Health = Health(language.Health, noHealth), + Complexity = noComplexity ? null : language.ComplexityTotal }).ToList(), Files = includeFiles ? summary.Files.Select(file => new JsonFile @@ -74,7 +76,8 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det Blank = file.Blank, BlankPct = Pct(file.Blank, file.Total), Total = file.Total, - Health = Health(file.Health, noHealth) + Health = Health(file.Health, noHealth), + Complexity = noComplexity ? null : file.Complexity }).ToList() : null, Skipped = summary.Skipped.Select(entry => new JsonSkipped @@ -129,6 +132,11 @@ public double? CommentPct get; init; } + public int? Complexity + { + get; init; + } + public string? Health { get; init; @@ -185,6 +193,11 @@ public double? CommentPct get; init; } + public int? Complexity + { + get; init; + } + public int Files { get; init; @@ -246,6 +259,11 @@ public double? CommentPct get; init; } + public int? Complexity + { + get; init; + } + public int FileCount { get; init; diff --git a/src/Sloc.Cli/Output/MarkdownRenderer.cs b/src/Sloc.Cli/Output/MarkdownRenderer.cs index 2610cf6..33254d7 100644 --- a/src/Sloc.Cli/Output/MarkdownRenderer.cs +++ b/src/Sloc.Cli/Output/MarkdownRenderer.cs @@ -30,7 +30,7 @@ public MarkdownRenderer(TextWriter? writer = null, DateTimeOffset? generatedAt = } /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null, bool noComplexity = false) { ArgumentNullException.ThrowIfNull(summary); @@ -52,7 +52,7 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det sb.AppendLine(); } - AppendLanguageTable(sb, summary, noHealth); + AppendLanguageTable(sb, summary, noHealth, noComplexity); } if (detailed || byFile) @@ -64,7 +64,7 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det sb.AppendLine(); } - AppendFileTable(sb, summary, noHealth); + AppendFileTable(sb, summary, noHealth, noComplexity); } if (summary.Skipped.Count > 0) @@ -75,7 +75,7 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det _writer.Write(sb.ToString()); } - private static void AppendFileTable(StringBuilder sb, AnalysisSummary summary, bool noHealth) + private static void AppendFileTable(StringBuilder sb, AnalysisSummary summary, bool noHealth, bool noComplexity) { var header = new List { "Path", "Language", "Code", "Comment", "Blank", "Total" }; var aligns = new List { ":---", ":---", "---:", "---:", "---:", "---:" }; @@ -84,6 +84,11 @@ private static void AppendFileTable(StringBuilder sb, AnalysisSummary summary, b header.Add("Health"); aligns.Add(":---"); } + if (!noComplexity) + { + header.Add("Complexity"); + aligns.Add("---:"); + } AppendRow(sb, header); AppendRow(sb, aligns); @@ -103,6 +108,10 @@ private static void AppendFileTable(StringBuilder sb, AnalysisSummary summary, b { row.Add(HealthCell(file.Health)); } + if (!noComplexity) + { + row.Add(ComplexityCell(file.Complexity)); + } AppendRow(sb, row); } @@ -120,11 +129,15 @@ private static void AppendFileTable(StringBuilder sb, AnalysisSummary summary, b { totalRow.Add(string.Empty); } + if (!noComplexity) + { + totalRow.Add(ComplexityCell(summary.ComplexityTotal)); + } AppendRow(sb, totalRow); } - private static void AppendLanguageTable(StringBuilder sb, AnalysisSummary summary, bool noHealth) + private static void AppendLanguageTable(StringBuilder sb, AnalysisSummary summary, bool noHealth, bool noComplexity) { // ':' alignment markers: language name left, numeric columns right. var header = new List { "Language", "Files", "Code", "Comment", "Blank", "Total" }; @@ -134,6 +147,11 @@ private static void AppendLanguageTable(StringBuilder sb, AnalysisSummary summar header.Add("Health"); aligns.Add(":---"); } + if (!noComplexity) + { + header.Add("Complexity"); + aligns.Add("---:"); + } AppendRow(sb, header); AppendRow(sb, aligns); @@ -153,6 +171,10 @@ private static void AppendLanguageTable(StringBuilder sb, AnalysisSummary summar { row.Add(HealthCell(language.Health)); } + if (!noComplexity) + { + row.Add(ComplexityCell(language.ComplexityTotal)); + } AppendRow(sb, row); } @@ -170,6 +192,10 @@ private static void AppendLanguageTable(StringBuilder sb, AnalysisSummary summar { totalRow.Add(string.Empty); } + if (!noComplexity) + { + totalRow.Add(ComplexityCell(summary.ComplexityTotal)); + } AppendRow(sb, totalRow); } @@ -196,8 +222,11 @@ private static void AppendSkippedSection(StringBuilder sb, AnalysisSummary summa } } + private static string ComplexityCell(int? complexity) => + complexity?.ToString("N0") ?? string.Empty; + private static string Escape(string cell) => - cell.Replace("|", "\\|").Replace("\r", " ").Replace("\n", " "); + cell.Replace("|", "\\|").Replace("\r", " ").Replace("\n", " "); private static string HealthCell(CommentHealthLevel health) => health == CommentHealthLevel.NotApplicable ? string.Empty : health.ToString(); diff --git a/src/Sloc.Cli/Output/TableRenderer.cs b/src/Sloc.Cli/Output/TableRenderer.cs index 6c19534..51f861a 100644 --- a/src/Sloc.Cli/Output/TableRenderer.cs +++ b/src/Sloc.Cli/Output/TableRenderer.cs @@ -14,15 +14,15 @@ public sealed class TableRenderer : IResultRenderer /// /// Table-specific capabilities that the shared signature /// cannot express — pagination and the live-refreshing progress table — are not - /// available through this method; calls + /// available through this method; calls /// and directly for those. /// has no Table equivalent (a table shows either the /// by-language or by-file view, never both) and is ignored, matching how the other /// renderers treat it as meaningless for this format. is - /// also ignored here; prints the analyzed path as a + /// also ignored here; prints the analyzed path as a /// separate banner line above the table instead. /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null, bool noComplexity = false) { ArgumentNullException.ThrowIfNull(summary); @@ -32,17 +32,17 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det } else if (byFile) { - RenderByFile(summary, noHealth); + RenderByFile(summary, noHealth, noComplexity: noComplexity); } else { - AnsiConsole.Write(BuildLanguageTable(summary, noHealth: noHealth)); + AnsiConsole.Write(BuildLanguageTable(summary, noHealth: noHealth, noComplexity: noComplexity)); } RenderSkipped(summary); } - internal Table BuildLanguageTable(AnalysisSummary summary, string? caption = null, bool noHealth = false) + internal Table BuildLanguageTable(AnalysisSummary summary, string? caption = null, bool noHealth = false, bool noComplexity = false) { var table = new Table().Border(TableBorder.Rounded); @@ -61,87 +61,87 @@ internal Table BuildLanguageTable(AnalysisSummary summary, string? caption = nul { table.AddColumn("Comment Health"); } + if (!noComplexity) + { + table.AddColumn(new TableColumn("Complexity").RightAligned()); + } foreach (var language in summary.ByLanguage) { var codeCell = noHealth ? language.Code.ToString("N0") : WithPercent(language.Code, language.Total); var commentCell = noHealth ? language.Comment.ToString("N0") : WithPercent(language.Comment, language.Total); var blankCell = noHealth ? language.Blank.ToString("N0") : WithPercent(language.Blank, language.Total); - if (noHealth) + var row = new List { - table.AddRow( - Markup.Escape(language.Language), - language.Files.ToString("N0"), - codeCell, - commentCell, - blankCell, - language.Total.ToString("N0")); + Markup.Escape(language.Language), + language.Files.ToString("N0"), + codeCell, + commentCell, + blankCell, + language.Total.ToString("N0") + }; + if (!noHealth) + { + row.Add(BuildHealthCell(language.Health)); } - else + if (!noComplexity) { - table.AddRow( - Markup.Escape(language.Language), - language.Files.ToString("N0"), - codeCell, - commentCell, - blankCell, - language.Total.ToString("N0"), - BuildHealthCell(language.Health)); + row.Add(BuildComplexityCell(language.ComplexityTotal)); } + + table.AddRow(row.ToArray()); } table.AddEmptyRow(); var totalCodeCell = noHealth ? $"[bold]{summary.Code:N0}[/]" : WithPercent(summary.Code, summary.Total, bold: true); var totalCommentCell = noHealth ? $"[bold]{summary.Comment:N0}[/]" : WithPercent(summary.Comment, summary.Total, bold: true); var totalBlankCell = noHealth ? $"[bold]{summary.Blank:N0}[/]" : WithPercent(summary.Blank, summary.Total, bold: true); - if (noHealth) + var totalRow = new List + { + $"[bold]{"Total"}[/]", + $"[bold]{summary.FileCount:N0}[/]", + totalCodeCell, + totalCommentCell, + totalBlankCell, + $"[bold]{summary.Total:N0}[/]" + }; + if (!noHealth) { - table.AddRow( - $"[bold]{"Total"}[/]", - $"[bold]{summary.FileCount:N0}[/]", - totalCodeCell, - totalCommentCell, - totalBlankCell, - $"[bold]{summary.Total:N0}[/]"); + totalRow.Add("[grey]—[/]"); } - else + if (!noComplexity) { - table.AddRow( - $"[bold]{"Total"}[/]", - $"[bold]{summary.FileCount:N0}[/]", - totalCodeCell, - totalCommentCell, - totalBlankCell, - $"[bold]{summary.Total:N0}[/]", - "[grey]—[/]"); + totalRow.Add(BuildComplexityCell(summary.ComplexityTotal, bold: true)); } + table.AddRow(totalRow.ToArray()); + return table; } - internal void RenderByFile(AnalysisSummary summary, bool noHealth, bool paged = false) + internal void RenderByFile(AnalysisSummary summary, bool noHealth, bool paged = false, bool noComplexity = false) { var files = summary.Files; var grouped = BuildGroupedItems(files); if (paged && ShouldPaginate(files.Count, out var pageSize)) { - RenderByFilePaged(summary, grouped, pageSize, noHealth); + RenderByFilePaged(summary, grouped, pageSize, noHealth, noComplexity); } else { - var table = CreateFileTable(noHealth); + var table = CreateFileTable(noHealth, noComplexity); foreach (var item in grouped) { if (item.IsFolder) { - AddFolderHeaderRow(table, item.FolderPath, noHealth, item.TreePrefix); + AddFolderHeaderRow(table, item.FolderPath, noHealth, noComplexity, item.TreePrefix); } else { - AddFileRow(table, item.File!, noHealth, indented: true, item.TreePrefix); + AddFileRow(table, item.File!, noHealth, noComplexity, indented: true, item.TreePrefix); } } - AddFileTotalRow(table, summary, noHealth); + AddFileTotalRow(table, summary, noHealth, noComplexity); AnsiConsole.Write(table); } } @@ -166,7 +166,7 @@ internal void RenderSkipped(AnalysisSummary summary) AnsiConsole.Write(table); } - private void AddFileRow(Table table, FileAnalysis file, bool noHealth, bool indented = false, string treePrefix = "") + private void AddFileRow(Table table, FileAnalysis file, bool noHealth, bool noComplexity, bool indented = false, string treePrefix = "") { var codeCell = noHealth ? file.Code.ToString("N0") : WithPercent(file.Code, file.Total); var commentCell = noHealth ? file.Comment.ToString("N0") : WithPercent(file.Comment, file.Total); @@ -174,71 +174,78 @@ private void AddFileRow(Table table, FileAnalysis file, bool noHealth, bool inde var fileCell = indented ? $"{treePrefix}{Markup.Escape(Path.GetFileName(file.Path))}" : Markup.Escape(ToRelative(file.Path)); - if (noHealth) + var row = new List + { + fileCell, + Markup.Escape(file.Language), + codeCell, + commentCell, + blankCell, + file.Total.ToString("N0") + }; + if (!noHealth) { - table.AddRow( - fileCell, - Markup.Escape(file.Language), - codeCell, - commentCell, - blankCell, - file.Total.ToString("N0")); + row.Add(BuildHealthCell(file.Health)); } - else + if (!noComplexity) { - table.AddRow( - fileCell, - Markup.Escape(file.Language), - codeCell, - commentCell, - blankCell, - file.Total.ToString("N0"), - BuildHealthCell(file.Health)); + row.Add(BuildComplexityCell(file.Complexity)); } + + table.AddRow(row.ToArray()); } - private void AddFileTotalRow(Table table, AnalysisSummary summary, bool noHealth) + private void AddFileTotalRow(Table table, AnalysisSummary summary, bool noHealth, bool noComplexity) { table.AddEmptyRow(); var totalCodeCell = noHealth ? $"[bold]{summary.Code:N0}[/]" : WithPercent(summary.Code, summary.Total, bold: true); var totalCommentCell = noHealth ? $"[bold]{summary.Comment:N0}[/]" : WithPercent(summary.Comment, summary.Total, bold: true); var totalBlankCell = noHealth ? $"[bold]{summary.Blank:N0}[/]" : WithPercent(summary.Blank, summary.Total, bold: true); - if (noHealth) + var row = new List + { + $"[bold]{"Total"}[/]", + $"[bold]{summary.FileCount:N0}[/]", + totalCodeCell, + totalCommentCell, + totalBlankCell, + $"[bold]{summary.Total:N0}[/]" + }; + if (!noHealth) { - table.AddRow( - $"[bold]{"Total"}[/]", - $"[bold]{summary.FileCount:N0}[/]", - totalCodeCell, - totalCommentCell, - totalBlankCell, - $"[bold]{summary.Total:N0}[/]"); + row.Add("[grey]—[/]"); } - else + if (!noComplexity) { - table.AddRow( - $"[bold]{"Total"}[/]", - $"[bold]{summary.FileCount:N0}[/]", - totalCodeCell, - totalCommentCell, - totalBlankCell, - $"[bold]{summary.Total:N0}[/]", - "[grey]—[/]"); + row.Add(BuildComplexityCell(summary.ComplexityTotal, bold: true)); } + + table.AddRow(row.ToArray()); } - private void AddFolderHeaderRow(Table table, string folder, bool noHealth, string treePrefix = "") + private void AddFolderHeaderRow(Table table, string folder, bool noHealth, bool noComplexity, string treePrefix = "") { var label = string.IsNullOrEmpty(folder) ? $"{treePrefix}[grey].[/]" : $"{treePrefix}[bold]📁 {Markup.Escape(Path.GetFileName(folder))}[/]"; - if (noHealth) + var columnCount = 5 + (noHealth ? 0 : 1) + (noComplexity ? 0 : 1); + var row = new string[columnCount + 1]; + row[0] = label; + for (var i = 1; i < row.Length; i++) { - table.AddRow(label, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); + row[i] = string.Empty; } - else + + table.AddRow(row); + } + + private string BuildComplexityCell(int? complexity, bool bold = false) + { + if (complexity is not { } value) { - table.AddRow(label, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); + return "[grey]—[/]"; } + + return bold ? $"[bold]{value:N0}[/]" : value.ToString("N0"); } private List BuildGroupedItems(IReadOnlyList files) @@ -307,7 +314,7 @@ private TreeNode BuildTree(IReadOnlyList files) return root; } - private Table CreateFileTable(bool noHealth) + private Table CreateFileTable(bool noHealth, bool noComplexity = false) { var table = new Table().Border(TableBorder.Rounded); table.AddColumn("File"); @@ -320,6 +327,10 @@ private Table CreateFileTable(bool noHealth) { table.AddColumn("Comment Health"); } + if (!noComplexity) + { + table.AddColumn(new TableColumn("Complexity").RightAligned()); + } return table; } @@ -340,32 +351,32 @@ private void FlattenNode(TreeNode node, string prefix, bool isLast, List grouped, int pageSize, bool noHealth) + private void RenderByFilePaged(AnalysisSummary summary, List grouped, int pageSize, bool noHealth, bool noComplexity) { var total = summary.Files.Count; var filesShown = 0; var itemIndex = 0; while (itemIndex < grouped.Count) { - var table = CreateFileTable(noHealth); + var table = CreateFileTable(noHealth, noComplexity); var filesInPage = 0; while (itemIndex < grouped.Count && filesInPage < pageSize) { var item = grouped[itemIndex++]; if (item.IsFolder) { - AddFolderHeaderRow(table, item.FolderPath, noHealth, item.TreePrefix); + AddFolderHeaderRow(table, item.FolderPath, noHealth, noComplexity, item.TreePrefix); } else { - AddFileRow(table, item.File!, noHealth, indented: true, item.TreePrefix); + AddFileRow(table, item.File!, noHealth, noComplexity, indented: true, item.TreePrefix); filesInPage++; } } filesShown += filesInPage; if (itemIndex >= grouped.Count) { - AddFileTotalRow(table, summary, noHealth); + AddFileTotalRow(table, summary, noHealth, noComplexity); } AnsiConsole.Write(table); diff --git a/src/Sloc.Cli/CliArgumentValidation.cs b/src/Sloc.Cli/Parsing/CliArgumentValidation.cs similarity index 90% rename from src/Sloc.Cli/CliArgumentValidation.cs rename to src/Sloc.Cli/Parsing/CliArgumentValidation.cs index 9303359..e521649 100644 --- a/src/Sloc.Cli/CliArgumentValidation.cs +++ b/src/Sloc.Cli/Parsing/CliArgumentValidation.cs @@ -1,4 +1,4 @@ -namespace Sloc.Cli; +namespace Sloc.Cli.Parsing; /// /// Validates numeric CLI arguments that System.CommandLine's parsing alone cannot @@ -13,7 +13,7 @@ internal static class CliArgumentValidation /// The parsed --min-comment-pct value, if any. /// if the value is valid; otherwise . public static bool IsValidMinCommentPct(double? value) => - value is null || (value >= 0 && value <= 100); + value is null or >= 0 and <= 100; /// /// Determines whether a --top value is 1 or greater. A @@ -22,5 +22,5 @@ public static bool IsValidMinCommentPct(double? value) => /// The parsed --top value, if any. /// if the value is valid; otherwise . public static bool IsValidTop(int? value) => - value is null || value >= 1; -} + value is null or >= 1; +} \ No newline at end of file diff --git a/src/Sloc.Cli/FormatResolver.cs b/src/Sloc.Cli/Parsing/FormatResolver.cs similarity index 97% rename from src/Sloc.Cli/FormatResolver.cs rename to src/Sloc.Cli/Parsing/FormatResolver.cs index 0a90de8..da628d0 100644 --- a/src/Sloc.Cli/FormatResolver.cs +++ b/src/Sloc.Cli/Parsing/FormatResolver.cs @@ -1,4 +1,6 @@ -namespace Sloc.Cli; +using Sloc.Cli.Analysis; + +namespace Sloc.Cli.Parsing; /// /// Resolves the effective from an explicit --format @@ -6,6 +8,22 @@ namespace Sloc.Cli; /// internal static class FormatResolver { + /// + /// Gets a value indicating whether an --output file path would be silently + /// ignored: a real file path was supplied (not and not the + /// stdout token -) but the resolved format is , + /// which never writes a file. + /// + /// The resolved output format. + /// The value of --output. + /// + /// when the output path will be ignored; otherwise . + /// + public static bool OutputIgnoredForTable(OutputFormat format, string? outputFile) => + format == OutputFormat.Table + && !string.IsNullOrEmpty(outputFile) + && outputFile != "-"; + /// /// Determines the output format to use. An explicit format always wins; otherwise the /// format is inferred from the output file's extension, falling back to @@ -41,20 +59,4 @@ public static OutputFormat Resolve(OutputFormat? explicitFormat, string? outputF _ => OutputFormat.Table }; } - - /// - /// Gets a value indicating whether an --output file path would be silently - /// ignored: a real file path was supplied (not and not the - /// stdout token -) but the resolved format is , - /// which never writes a file. - /// - /// The resolved output format. - /// The value of --output. - /// - /// when the output path will be ignored; otherwise . - /// - public static bool OutputIgnoredForTable(OutputFormat format, string? outputFile) => - format == OutputFormat.Table - && !string.IsNullOrEmpty(outputFile) - && outputFile != "-"; -} +} \ No newline at end of file diff --git a/src/Sloc.Cli/Program.cs b/src/Sloc.Cli/Program.cs index bc5c4d1..f9ccec8 100644 --- a/src/Sloc.Cli/Program.cs +++ b/src/Sloc.Cli/Program.cs @@ -1,4 +1,5 @@ -using Sloc.Cli; +using Sloc.Cli.Analysis; +using Sloc.Cli.Parsing; using Sloc.Core.Languages; using Sloc.Core.Models; using System.CommandLine; @@ -79,6 +80,11 @@ Description = "Hide the Comment Health column and percentage breakdowns." }; +var noComplexityOption = new Option("--no-complexity") +{ + Description = "Hide the Complexity column/field." +}; + var byFileOption = new Option("--by-file") { Description = "Show a per-file breakdown in addition to the language summary." @@ -184,6 +190,7 @@ allOption, outputOption, noHealthOption, + noComplexityOption, quietOption, noProgressOption, minCommentPctOption, @@ -261,6 +268,7 @@ .. parseResult.GetValue(excludeOption) ?? [], IncludeUnknown = parseResult.GetValue(allOption), OutputFile = outputFile, NoHealth = parseResult.GetValue(noHealthOption), + NoComplexity = parseResult.GetValue(noComplexityOption), Quiet = parseResult.GetValue(quietOption), NoProgress = parseResult.GetValue(noProgressOption), MinCommentPct = minCommentPct, diff --git a/src/Sloc.Cli/UpdateChecker.cs b/src/Sloc.Cli/Updates/UpdateChecker.cs similarity index 99% rename from src/Sloc.Cli/UpdateChecker.cs rename to src/Sloc.Cli/Updates/UpdateChecker.cs index 17f2d93..d578047 100644 --- a/src/Sloc.Cli/UpdateChecker.cs +++ b/src/Sloc.Cli/Updates/UpdateChecker.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; -namespace Sloc.Cli; +namespace Sloc.Cli.Updates; /// /// The result of a successful update check: a newer release is available. @@ -214,4 +214,4 @@ private static HttpClient CreateHttpClient() private sealed record GitHubRelease( [property: JsonPropertyName("tag_name")] string? TagName, [property: JsonPropertyName("html_url")] string? HtmlUrl); -} +} \ No newline at end of file diff --git a/src/Sloc.Core/FileAnalyzer.cs b/src/Sloc.Core/FileAnalyzer.cs index 9b20687..eb13d7e 100644 --- a/src/Sloc.Core/FileAnalyzer.cs +++ b/src/Sloc.Core/FileAnalyzer.cs @@ -62,53 +62,70 @@ public FileAnalysis Analyze(string path, LanguageDefinition language, bool compu Code = analysis.Code, Comment = analysis.Comment, Blank = analysis.Blank, + Complexity = analysis.Complexity, Hash = hash }; } /// - /// A read-only stream wrapper that feeds every byte read from the inner stream into an - /// , so a caller can compute a content hash while reading - /// without a second pass over the file. + /// Analyzes in-memory using the supplied language. + /// Primarily intended for testing. /// - private sealed class HashingStream(Stream inner, IncrementalHash hash) : Stream + /// The source text to analyze. + /// The language whose comment rules drive classification. + /// An optional display path to record on the result. + /// + /// The line statistics for the content. + /// + public FileAnalysis AnalyzeText(string content, LanguageDefinition language, string path = "(memory)") { - public override bool CanRead => true; - - public override bool CanSeek => false; + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(language); - public override bool CanWrite => false; + using var reader = new StringReader(content); + return Count(path, language, reader); + } - public override long Length => throw new NotSupportedException(); + private static FileAnalysis Count(string path, LanguageDefinition language, TextReader reader) + { + var classifier = new LineClassifier(language); + var code = 0; + var comment = 0; + var blank = 0; + var supportsComplexity = language.SupportsComplexity; + var complexity = supportsComplexity ? 1 : 0; - public override long Position + while (reader.ReadLine() is { } line) { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } + switch (classifier.Classify(line)) + { + case LineKind.Code: + code++; + if (supportsComplexity) + { + complexity += language.ComplexityRegex.Count(line); + } + break; - public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + case LineKind.Comment: + comment++; + break; - public override int Read(Span buffer) - { - var read = inner.Read(buffer); - if (read > 0) - { - hash.AppendData(buffer[..read]); + default: + blank++; + break; } - - return read; } - public override void Flush() + return new FileAnalysis { - } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - - public override void SetLength(long value) => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + Path = path, + Language = language.Name, + Code = code, + Comment = comment, + Blank = blank, + Complexity = supportsComplexity ? complexity : null + }; } /// @@ -212,56 +229,47 @@ private static bool HasTextBom(ReadOnlySpan bytes) } /// - /// Analyzes in-memory using the supplied language. - /// Primarily intended for testing. + /// A read-only stream wrapper that feeds every byte read from the inner stream into an + /// , so a caller can compute a content hash while reading + /// without a second pass over the file. /// - /// The source text to analyze. - /// The language whose comment rules drive classification. - /// An optional display path to record on the result. - /// - /// The line statistics for the content. - /// - public FileAnalysis AnalyzeText(string content, LanguageDefinition language, string path = "(memory)") + private sealed class HashingStream(Stream inner, IncrementalHash hash) : Stream { - ArgumentNullException.ThrowIfNull(content); - ArgumentNullException.ThrowIfNull(language); + public override bool CanRead => true; - using var reader = new StringReader(content); - return Count(path, language, reader); - } + public override bool CanSeek => false; - private static FileAnalysis Count(string path, LanguageDefinition language, TextReader reader) - { - var classifier = new LineClassifier(language); - var code = 0; - var comment = 0; - var blank = 0; + public override bool CanWrite => false; - while (reader.ReadLine() is { } line) + public override long Length => throw new NotSupportedException(); + + public override long Position { - switch (classifier.Classify(line)) - { - case LineKind.Code: - code++; - break; + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } - case LineKind.Comment: - comment++; - break; + public override void Flush() + { + } - default: - blank++; - break; + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + var read = inner.Read(buffer); + if (read > 0) + { + hash.AppendData(buffer[..read]); } + + return read; } - return new FileAnalysis - { - Path = path, - Language = language.Name, - Code = code, - Comment = comment, - Blank = blank - }; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } } \ No newline at end of file diff --git a/src/Sloc.Core/Languages/LanguageDefinition.cs b/src/Sloc.Core/Languages/LanguageDefinition.cs index b264a68..5838661 100644 --- a/src/Sloc.Core/Languages/LanguageDefinition.cs +++ b/src/Sloc.Core/Languages/LanguageDefinition.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Sloc.Core.Languages; /// @@ -77,6 +79,8 @@ public sealed class LanguageDefinition private Dictionary? _stringLiteralsByFirstChar; + private Regex? _complexityRegex; + /// /// The delimiter pairs that start and end block comments (e.g. /**/). /// @@ -115,6 +119,15 @@ public IReadOnlyList Extensions /// public IReadOnlyList FilenameSuffixes { get; init; } = []; + /// + /// The tokens that count as a branch point for the simplified cyclomatic-complexity + /// metric (e.g. if, for, &&, ?:). Tokens consisting + /// entirely of letters are matched as whole words; other tokens are matched literally. + /// Empty for languages where this metric is not meaningful (see + /// ). + /// + public IReadOnlyList ComplexityKeywords { get; init; } = []; + /// /// The tokens that start a single-line comment (e.g. // or #). /// A token consisting entirely of letters/digits (e.g. REM) is only recognized @@ -153,6 +166,21 @@ public required string Name public bool SupportsHealth => ShowHealth && (LineCommentTokens.Count > 0 || BlockComments.Count > 0); + /// + /// Gets a value indicating whether the simplified cyclomatic-complexity metric is + /// meaningful for this language: it defines at least one + /// token. + /// + public bool SupportsComplexity => ComplexityKeywords.Count > 0; + + /// + /// A compiled regex matching any token, used to count + /// branch points in a code line. Alphabetic tokens are matched as whole words; other + /// tokens (e.g. &&, ?:) are matched literally. Built lazily and + /// cached for the lifetime of this (shared, immutable) definition. + /// + internal Regex ComplexityRegex => _complexityRegex ??= BuildComplexityRegex(ComplexityKeywords); + /// /// grouped by the first character of , /// so can skip straight to the candidates that could possibly @@ -180,6 +208,22 @@ public required string Name internal Dictionary StringLiteralsByFirstChar => _stringLiteralsByFirstChar ??= GroupByFirstChar(StringLiterals, s => s.Delimiter, c => c); + private static Regex BuildComplexityRegex(IReadOnlyList keywords) + { + if (keywords.Count == 0) + { + // Never matched; kept simple rather than special-cased since SupportsComplexity + // guards every call site that would otherwise use this regex. + return new Regex("(?!)", RegexOptions.Compiled); + } + + var alternatives = keywords + .OrderByDescending(keyword => keyword.Length) + .Select(keyword => keyword.All(char.IsLetter) ? $@"\b{Regex.Escape(keyword)}\b" : Regex.Escape(keyword)); + + return new Regex(string.Join('|', alternatives), RegexOptions.Compiled); + } + private static Dictionary GroupByFirstChar( IReadOnlyList items, Func tokenSelector, diff --git a/src/Sloc.Core/Languages/LanguageRegistry.cs b/src/Sloc.Core/Languages/LanguageRegistry.cs index 95022f9..5af53fb 100644 --- a/src/Sloc.Core/Languages/LanguageRegistry.cs +++ b/src/Sloc.Core/Languages/LanguageRegistry.cs @@ -14,6 +14,8 @@ public static class LanguageRegistry private static readonly IReadOnlyList<(string Suffix, LanguageDefinition Language)> SuffixLookup = CreateSuffixLookup(AllLanguages); private static readonly IReadOnlyDictionary HealthSupportByName = AllLanguages.ToDictionary(language => language.Name, language => language.SupportsHealth, StringComparer.OrdinalIgnoreCase); + private static readonly IReadOnlyDictionary ComplexitySupportByName = + AllLanguages.ToDictionary(language => language.Name, language => language.SupportsComplexity, StringComparer.OrdinalIgnoreCase); /// /// Gets every language known to the registry. @@ -83,6 +85,17 @@ public static bool TryGetByPath(string path, [NotNullWhen(true)] out LanguageDef public static bool SupportsHealth(string? name) => name is not null && HealthSupportByName.TryGetValue(name, out var supports) && supports; + /// + /// Determines whether the simplified cyclomatic-complexity metric is meaningful for the + /// language with the given display name. Unknown names are treated as unsupported. + /// + /// The language display name (e.g. "C#"). + /// + /// if the language supports complexity analysis; otherwise . + /// + public static bool SupportsComplexity(string? name) => + name is not null && ComplexitySupportByName.TryGetValue(name, out var supports) && supports; + private static IReadOnlyList CreateLanguages() { var cStyleBlock = new BlockComment("/*", "*/"); @@ -114,6 +127,15 @@ private static IReadOnlyList CreateLanguages() AllowEscape: false, CloseDelimiter: "\"#"); + // Shared simplified cyclomatic-complexity keyword sets: branch points that add a + // decision point to a function's control flow. "else if"/"elif" are not listed + // separately since the shared "if" token already matches inside them. + string[] cStyleComplexity = ["if", "for", "while", "case", "catch", "&&", "||", "?:"]; + string[] cComplexity = ["if", "for", "while", "case", "&&", "||", "?:"]; + string[] pythonComplexity = ["if", "elif", "for", "while", "except", "and", "or"]; + string[] rubyComplexity = ["if", "elsif", "for", "while", "case", "rescue", "&&", "||"]; + string[] rustComplexity = ["if", "for", "while", "match", "&&", "||"]; + return new List { new() @@ -122,7 +144,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".cs", ".csx"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [csVerbatimString, doubleQuote, singleQuote] + StringLiterals = [csVerbatimString, doubleQuote, singleQuote], + ComplexityKeywords = cStyleComplexity }, new() { @@ -130,7 +153,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".c", ".h"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [doubleQuote, singleQuote] + StringLiterals = [doubleQuote, singleQuote], + ComplexityKeywords = cComplexity }, new() { @@ -138,7 +162,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".cpp", ".hpp", ".cc", ".cxx", ".hxx", ".ipp"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [doubleQuote, singleQuote] + StringLiterals = [doubleQuote, singleQuote], + ComplexityKeywords = cStyleComplexity }, new() { @@ -146,7 +171,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".java"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [doubleQuote, singleQuote] + StringLiterals = [doubleQuote, singleQuote], + ComplexityKeywords = cStyleComplexity }, new() { @@ -154,7 +180,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".kt", ".kts"], LineCommentTokens = ["//"], BlockComments = [nestedCStyleBlock], - StringLiterals = [rawTripleDouble, doubleQuote, singleQuote] + StringLiterals = [rawTripleDouble, doubleQuote, singleQuote], + ComplexityKeywords = ["if", "for", "while", "when", "catch", "&&", "||"] }, new() { @@ -162,7 +189,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".swift"], LineCommentTokens = ["//"], BlockComments = [nestedCStyleBlock], - StringLiterals = [rawTripleDouble, doubleQuote] + StringLiterals = [rawTripleDouble, doubleQuote], + ComplexityKeywords = ["if", "for", "while", "case", "catch", "guard", "&&", "||"] }, new() { @@ -170,7 +198,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".js", ".jsx", ".mjs", ".cjs"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [backtickTemplate, doubleQuote, singleQuote] + StringLiterals = [backtickTemplate, doubleQuote, singleQuote], + ComplexityKeywords = cStyleComplexity }, new() { @@ -178,14 +207,16 @@ private static IReadOnlyList CreateLanguages() Extensions = [".ts", ".tsx", ".mts", ".cts"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [backtickTemplate, doubleQuote, singleQuote] + StringLiterals = [backtickTemplate, doubleQuote, singleQuote], + ComplexityKeywords = cStyleComplexity }, new() { Name = "Python", Extensions = [".py", ".pyw"], LineCommentTokens = ["#"], - StringLiterals = [pyTripleDouble, pyTripleSingle, doubleQuote, singleQuote] + StringLiterals = [pyTripleDouble, pyTripleSingle, doubleQuote, singleQuote], + ComplexityKeywords = pythonComplexity }, new() { @@ -193,7 +224,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".go"], LineCommentTokens = ["//"], BlockComments = [cStyleBlock], - StringLiterals = [backtickRaw, doubleQuote, singleQuote] + StringLiterals = [backtickRaw, doubleQuote, singleQuote], + ComplexityKeywords = ["if", "for", "case", "&&", "||"] }, new() { @@ -203,7 +235,8 @@ private static IReadOnlyList CreateLanguages() BlockComments = [nestedCStyleBlock], // Single quotes denote lifetimes as well as char literals in Rust, so they // are not treated as string delimiters here. - StringLiterals = [rustRawStringOneHash, rustRawStringNoHash, doubleQuote] + StringLiterals = [rustRawStringOneHash, rustRawStringNoHash, doubleQuote], + ComplexityKeywords = rustComplexity }, new() { @@ -211,7 +244,8 @@ private static IReadOnlyList CreateLanguages() Extensions = [".php"], LineCommentTokens = ["//", "#"], BlockComments = [cStyleBlock], - StringLiterals = [doubleQuote, singleQuote] + StringLiterals = [doubleQuote, singleQuote], + ComplexityKeywords = cStyleComplexity }, new() { @@ -220,7 +254,8 @@ private static IReadOnlyList CreateLanguages() Filenames = ["Rakefile", "Gemfile", "Guardfile", "Podfile"], LineCommentTokens = ["#"], BlockComments = [new BlockComment("=begin", "=end", RequireLineStart: true)], - StringLiterals = [doubleQuote, singleQuote] + StringLiterals = [doubleQuote, singleQuote], + ComplexityKeywords = rubyComplexity }, new() { diff --git a/src/Sloc.Core/Models/AnalysisSummary.cs b/src/Sloc.Core/Models/AnalysisSummary.cs index 5ef990b..0cb01f7 100644 --- a/src/Sloc.Core/Models/AnalysisSummary.cs +++ b/src/Sloc.Core/Models/AnalysisSummary.cs @@ -1,3 +1,5 @@ +using Sloc.Core.Languages; + namespace Sloc.Core.Models; /// @@ -73,6 +75,23 @@ public int Files get; init; } + /// + /// The sum of across every file counted for this + /// language, or when the language does not support complexity + /// analysis (). + /// + public int? ComplexityTotal + { + get; init; + } + + /// + /// The average per-file complexity for this language ( + /// divided by ), or when complexity is not + /// supported. + /// + public double? ComplexityAverage => ComplexityTotal.HasValue && Files > 0 ? (double)ComplexityTotal / Files : null; + /// /// The display name of the language. /// @@ -151,6 +170,9 @@ public AnalysisSummary( Code = code; Comment = comment; Blank = blank; + ComplexityTotal = files.Any(file => LanguageRegistry.SupportsComplexity(file.Language)) + ? files.Sum(file => file.Complexity ?? 0) + : null; var grouped = files .GroupBy(file => file.Language, StringComparer.OrdinalIgnoreCase) @@ -160,7 +182,10 @@ public AnalysisSummary( Files = group.Count(), Code = group.Sum(file => file.Code), Comment = group.Sum(file => file.Comment), - Blank = group.Sum(file => file.Blank) + Blank = group.Sum(file => file.Blank), + ComplexityTotal = LanguageRegistry.SupportsComplexity(group.Key) + ? group.Sum(file => file.Complexity ?? 0) + : null }); ByLanguage = OrderAndLimit(grouped, sortBy, descending, top); @@ -239,16 +264,24 @@ public AnalysisSummary(IReadOnlyList byLanguage, int fileCou var code = 0; var comment = 0; var blank = 0; + var hasComplexitySupport = false; + var complexityTotal = 0; foreach (var language in byLanguage) { code += language.Code; comment += language.Comment; blank += language.Blank; + if (language.ComplexityTotal is { } languageComplexity) + { + hasComplexitySupport = true; + complexityTotal += languageComplexity; + } } Code = code; Comment = comment; Blank = blank; + ComplexityTotal = hasComplexitySupport ? complexityTotal : null; ByLanguage = byLanguage; } @@ -260,6 +293,16 @@ public int Blank get; } + /// + /// The sum of across every analyzed file that + /// supports complexity analysis, or when no contributing + /// language does (). + /// + public int? ComplexityTotal + { + get; + } + /// /// The aggregated statistics grouped by language, ordered by total lines descending. /// diff --git a/src/Sloc.Core/Models/FileAnalysis.cs b/src/Sloc.Core/Models/FileAnalysis.cs index 0bb6918..c02218c 100644 --- a/src/Sloc.Core/Models/FileAnalysis.cs +++ b/src/Sloc.Core/Models/FileAnalysis.cs @@ -54,6 +54,16 @@ public string? Hash get; init; } + /// + /// The simplified cyclomatic complexity of the file (1 + the number of branch-point + /// tokens found in its code lines), or when the language does + /// not support complexity analysis (). + /// + public int? Complexity + { + get; init; + } + /// /// The total number of physical lines in the file. /// diff --git a/src/Sloc.Core/DirectoryScanner.cs b/src/Sloc.Core/Scanning/DirectoryScanner.cs similarity index 99% rename from src/Sloc.Core/DirectoryScanner.cs rename to src/Sloc.Core/Scanning/DirectoryScanner.cs index f0b146f..4a8b134 100644 --- a/src/Sloc.Core/DirectoryScanner.cs +++ b/src/Sloc.Core/Scanning/DirectoryScanner.cs @@ -2,7 +2,7 @@ using Sloc.Core.Languages; using Sloc.Core.Models; -namespace Sloc.Core; +namespace Sloc.Core.Scanning; /// /// A file discovered by the , paired with the diff --git a/src/Sloc.Core/GitAttributesRules.cs b/src/Sloc.Core/Scanning/GitAttributesRules.cs similarity index 99% rename from src/Sloc.Core/GitAttributesRules.cs rename to src/Sloc.Core/Scanning/GitAttributesRules.cs index 289c6c2..3e01731 100644 --- a/src/Sloc.Core/GitAttributesRules.cs +++ b/src/Sloc.Core/Scanning/GitAttributesRules.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Sloc.Core; +namespace Sloc.Core.Scanning; /// /// Evaluates whether a path is marked vendored or generated according to a set of diff --git a/src/Sloc.Core/GitIgnoreRules.cs b/src/Sloc.Core/Scanning/GitIgnoreRules.cs similarity index 99% rename from src/Sloc.Core/GitIgnoreRules.cs rename to src/Sloc.Core/Scanning/GitIgnoreRules.cs index e1e1636..8f45860 100644 --- a/src/Sloc.Core/GitIgnoreRules.cs +++ b/src/Sloc.Core/Scanning/GitIgnoreRules.cs @@ -2,7 +2,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace Sloc.Core; +namespace Sloc.Core.Scanning; /// /// Evaluates whether a path is ignored according to a set of .gitignore files, diff --git a/src/Sloc.Core/RelativePathResolver.cs b/src/Sloc.Core/Scanning/RelativePathResolver.cs similarity index 98% rename from src/Sloc.Core/RelativePathResolver.cs rename to src/Sloc.Core/Scanning/RelativePathResolver.cs index 21e5646..2b5fffe 100644 --- a/src/Sloc.Core/RelativePathResolver.cs +++ b/src/Sloc.Core/Scanning/RelativePathResolver.cs @@ -1,4 +1,4 @@ -namespace Sloc.Core; +namespace Sloc.Core.Scanning; /// /// Shared "make a scan-root-relative path relative to a rule file's own base directory" diff --git a/src/Sloc.Core/ScanTreeWalker.cs b/src/Sloc.Core/Scanning/ScanTreeWalker.cs similarity index 99% rename from src/Sloc.Core/ScanTreeWalker.cs rename to src/Sloc.Core/Scanning/ScanTreeWalker.cs index bb4c9b4..45ff91b 100644 --- a/src/Sloc.Core/ScanTreeWalker.cs +++ b/src/Sloc.Core/Scanning/ScanTreeWalker.cs @@ -1,6 +1,6 @@ using Sloc.Core.Models; -namespace Sloc.Core; +namespace Sloc.Core.Scanning; /// /// The result of a single pass: every .gitignore diff --git a/src/Sloc.Core/SymlinkGuard.cs b/src/Sloc.Core/Scanning/SymlinkGuard.cs similarity index 99% rename from src/Sloc.Core/SymlinkGuard.cs rename to src/Sloc.Core/Scanning/SymlinkGuard.cs index b27e34a..86f9d15 100644 --- a/src/Sloc.Core/SymlinkGuard.cs +++ b/src/Sloc.Core/Scanning/SymlinkGuard.cs @@ -1,4 +1,4 @@ -namespace Sloc.Core; +namespace Sloc.Core.Scanning; /// /// Shared directory-symlink/junction loop detection, used by both diff --git a/tests/Sloc.Cli.Tests/AnalyzeHandlerGitHashTests.cs b/tests/Sloc.Cli.Tests/AnalyzeHandlerGitHashTests.cs index 098643c..48443d0 100644 --- a/tests/Sloc.Cli.Tests/AnalyzeHandlerGitHashTests.cs +++ b/tests/Sloc.Cli.Tests/AnalyzeHandlerGitHashTests.cs @@ -1,3 +1,4 @@ +using Sloc.Cli.Analysis; using System.Diagnostics; using System.Text.Json; diff --git a/tests/Sloc.Cli.Tests/AnalyzeHandlerTests.cs b/tests/Sloc.Cli.Tests/AnalyzeHandlerTests.cs index ea596fb..646e491 100644 --- a/tests/Sloc.Cli.Tests/AnalyzeHandlerTests.cs +++ b/tests/Sloc.Cli.Tests/AnalyzeHandlerTests.cs @@ -1,3 +1,4 @@ +using Sloc.Cli.Analysis; using System.Text.Json; using System.Text.RegularExpressions; diff --git a/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs b/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs index 65fb6c4..ebdc698 100644 --- a/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs +++ b/tests/Sloc.Cli.Tests/CliArgumentValidationTests.cs @@ -1,3 +1,5 @@ +using Sloc.Cli.Parsing; + namespace Sloc.Cli.Tests; /// diff --git a/tests/Sloc.Cli.Tests/CsvRendererTests.cs b/tests/Sloc.Cli.Tests/CsvRendererTests.cs index 971180c..ff42b27 100644 --- a/tests/Sloc.Cli.Tests/CsvRendererTests.cs +++ b/tests/Sloc.Cli.Tests/CsvRendererTests.cs @@ -18,7 +18,7 @@ public void Render_ByLanguage_WritesHeaderAndRows() var summary = BuildSummary("a.cs"); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: false, noHealth: false); + new CsvRenderer(writer).Render(summary, byFile: false, noHealth: false, noComplexity: true); var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Language,Files,Code,Comment,Blank,Total,Health", lines[0]); @@ -34,13 +34,49 @@ public void Render_NoHealth_OmitsHealthColumn() var summary = BuildSummary("a.cs"); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true); + new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true, noComplexity: true); var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Language,Files,Code,Comment,Blank,Total", lines[0]); Assert.DoesNotContain("Health", lines[0]); } + /// + /// Verifies that noComplexity drops the Complexity column from the header and rows. + /// + [Fact] + public void Render_NoComplexity_OmitsComplexityColumn() + { + var summary = BuildSummary("a.cs"); + 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("Language,Files,Code,Comment,Blank,Total", lines[0]); + Assert.DoesNotContain("Complexity", lines[0]); + } + + /// + /// Verifies that the Complexity column is present by default, with C#'s per-file + /// complexity value and an empty cell for a language that does not support it. + /// + [Fact] + public void Render_Complexity_PopulatesForSupportedLanguageOnly() + { + var supported = new FileAnalysis { Path = "a.cs", Language = "C#", Code = 80, Comment = 20, Blank = 5, Complexity = 4 }; + var unsupported = new FileAnalysis { Path = "b.yml", Language = "YAML", Code = 10, Comment = 0, Blank = 0 }; + var summary = new AnalysisSummary([supported, unsupported]); + using var writer = new StringWriter(); + + new CsvRenderer(writer).Render(summary, byFile: true, noHealth: true); + var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal("Path,Language,Code,Comment,Blank,Total,Complexity", lines[0]); + Assert.Equal("a.cs,C#,80,20,5,105,4", lines[1]); + Assert.Equal("b.yml,YAML,10,0,0,10,", lines[2]); + } + /// /// Verifies that a path containing a comma is quoted so the CSV stays well-formed. /// @@ -50,7 +86,7 @@ public void Render_ByFile_QuotesPathsWithCommas() var summary = BuildSummary("weird, name.cs"); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: true, noHealth: true); + new CsvRenderer(writer).Render(summary, byFile: true, noHealth: true, noComplexity: true); var text = writer.ToString(); Assert.Contains("\"weird, name.cs\"", text); @@ -65,7 +101,7 @@ public void Render_ByLanguage_AppendsTotalRow() var summary = BuildSummary("a.cs"); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true); + new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true, noComplexity: true); var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Total,1,80,20,5,105", lines[^1]); @@ -80,7 +116,7 @@ public void Render_ByFile_AppendsTotalRow() var summary = BuildSummary("a.cs"); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: true, noHealth: true); + new CsvRenderer(writer).Render(summary, byFile: true, noHealth: true, noComplexity: true); var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Total,,80,20,5,105", lines[^1]); @@ -96,7 +132,7 @@ public void Render_Detailed_EmitsLanguageSummaryOnly() var summary = BuildSummary("a.cs"); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true, detailed: true); + new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true, detailed: true, noComplexity: true); var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Language,Files,Code,Comment,Blank,Total", lines[0]); @@ -112,7 +148,7 @@ public void Render_WithSkippedFiles_AppendsSkippedTable() var summary = BuildSummary("a.cs", skipped: [new SkippedEntry("bad.cs", "binary file")]); using var writer = new StringWriter(); - new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true); + new CsvRenderer(writer).Render(summary, byFile: false, noHealth: true, noComplexity: true); var lines = writer.ToString().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Path,Reason", lines[^2]); diff --git a/tests/Sloc.Cli.Tests/FormatResolverTests.cs b/tests/Sloc.Cli.Tests/FormatResolverTests.cs index 5d45c6b..cfa9328 100644 --- a/tests/Sloc.Cli.Tests/FormatResolverTests.cs +++ b/tests/Sloc.Cli.Tests/FormatResolverTests.cs @@ -1,3 +1,6 @@ +using Sloc.Cli.Analysis; +using Sloc.Cli.Parsing; + namespace Sloc.Cli.Tests; /// diff --git a/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs b/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs index 7765cc6..771dfde 100644 --- a/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs +++ b/tests/Sloc.Cli.Tests/MarkdownRendererTests.cs @@ -17,7 +17,7 @@ public void Render_ByLanguage_WritesTitleHeaderSeparatorAndRow() { var summary = BuildSummary("a.cs"); - var text = Render(summary, byFile: false, noHealth: false); + var text = Render(summary, byFile: false, noHealth: false, noComplexity: true); var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(line => line.TrimEnd('\r')) .ToArray(); @@ -29,6 +29,34 @@ public void Render_ByLanguage_WritesTitleHeaderSeparatorAndRow() Assert.Contains(lines, line => line.StartsWith("| C# | 1 | 80 | 20 | 5 | 105 |")); } + /// + /// Verifies that the Complexity column is present by default with the per-language total. + /// + [Fact] + public void Render_Complexity_WritesComplexityColumn() + { + var file = new FileAnalysis { Path = "a.cs", Language = "C#", Code = 80, Comment = 20, Blank = 5, Complexity = 4 }; + var summary = new AnalysisSummary([file]); + + var text = Render(summary, byFile: false, noHealth: true); + + Assert.Contains("| Language | Files | Code | Comment | Blank | Total | Complexity |", text); + Assert.Contains("| C# | 1 | 80 | 20 | 5 | 105 | 4 |", text); + } + + /// + /// Verifies that noComplexity drops the Complexity column. + /// + [Fact] + public void Render_NoComplexity_OmitsComplexityColumn() + { + var summary = BuildSummary("a.cs"); + + var text = Render(summary, byFile: false, noHealth: true, noComplexity: true); + + Assert.DoesNotContain("Complexity", text); + } + /// /// Verifies that the by-language table ends with a bolded Total row summing all languages. /// @@ -37,7 +65,7 @@ public void Render_ByLanguage_AppendsTotalRow() { var summary = BuildSummary("a.cs"); - var text = Render(summary, byFile: false, noHealth: false); + var text = Render(summary, byFile: false, noHealth: false, noComplexity: true); Assert.Contains("| **Total** | 1 | 80 | 20 | 5 | 105 |", text); } @@ -50,7 +78,7 @@ public void Render_ByFile_AppendsTotalRow() { var summary = BuildSummary("a.cs"); - var text = Render(summary, byFile: true, noHealth: false); + var text = Render(summary, byFile: true, noHealth: false, noComplexity: true); Assert.Contains("| **Total** | | 80 | 20 | 5 | 105 |", text); } @@ -92,7 +120,7 @@ public void Render_NoHealth_OmitsHealthColumn() { var summary = BuildSummary("a.cs"); - var text = Render(summary, byFile: false, noHealth: true); + var text = Render(summary, byFile: false, noHealth: true, noComplexity: true); Assert.Contains("| Language | Files | Code | Comment | Blank | Total |", text); Assert.DoesNotContain("Health", text); @@ -106,7 +134,7 @@ public void Render_ByFile_EscapesPipesInPaths() { var summary = BuildSummary("weird|name.cs"); - var text = Render(summary, byFile: true, noHealth: true); + var text = Render(summary, byFile: true, noHealth: true, noComplexity: true); Assert.Contains("weird\\|name.cs", text); } @@ -126,10 +154,10 @@ public void Render_WithSkipped_AppendsSkippedSection() Assert.Contains("- bad.bin — binary file", text); } - private static string Render(AnalysisSummary summary, bool byFile, bool noHealth) + private static string Render(AnalysisSummary summary, bool byFile, bool noHealth, bool noComplexity = false) { using var writer = new StringWriter(); - new MarkdownRenderer(writer).Render(summary, byFile, noHealth); + new MarkdownRenderer(writer).Render(summary, byFile, noHealth, noComplexity: noComplexity); return writer.ToString(); } diff --git a/tests/Sloc.Cli.Tests/TableRendererTests.cs b/tests/Sloc.Cli.Tests/TableRendererTests.cs index c9012c6..02c5710 100644 --- a/tests/Sloc.Cli.Tests/TableRendererTests.cs +++ b/tests/Sloc.Cli.Tests/TableRendererTests.cs @@ -19,8 +19,9 @@ public void BuildLanguageTable_WithHealth_RendersLanguageRowAndHealthColumn() { var summary = BuildSummary(); var console = new TestConsole(); + console.Profile.Width = 120; - console.Write(new TableRenderer().BuildLanguageTable(summary, noHealth: false)); + console.Write(new TableRenderer().BuildLanguageTable(summary, noHealth: false, noComplexity: true)); var output = console.Output; Assert.Contains("C#", output); @@ -36,14 +37,49 @@ public void BuildLanguageTable_NoHealth_OmitsHealthColumn() { var summary = BuildSummary(); var console = new TestConsole(); + console.Profile.Width = 120; - console.Write(new TableRenderer().BuildLanguageTable(summary, noHealth: true)); + console.Write(new TableRenderer().BuildLanguageTable(summary, noHealth: true, noComplexity: true)); var output = console.Output; Assert.Contains("C#", output); Assert.DoesNotContain("Comment Health", output); } + /// + /// Verifies that the Complexity column is present by default with the per-language total. + /// + [Fact] + public void BuildLanguageTable_WithComplexity_RendersComplexityColumn() + { + var file = new FileAnalysis { Path = "a.cs", Language = "C#", Code = 80, Comment = 20, Blank = 5, Complexity = 4 }; + var summary = new AnalysisSummary([file]); + var console = new TestConsole(); + console.Profile.Width = 120; + + console.Write(new TableRenderer().BuildLanguageTable(summary, noHealth: true, noComplexity: false)); + var output = console.Output; + + Assert.Contains("Complexity", output); + Assert.Contains("4", output); + } + + /// + /// Verifies that noComplexity drops the Complexity column entirely. + /// + [Fact] + public void BuildLanguageTable_NoComplexity_OmitsComplexityColumn() + { + var summary = BuildSummary(); + var console = new TestConsole(); + console.Profile.Width = 120; + + console.Write(new TableRenderer().BuildLanguageTable(summary, noHealth: true, noComplexity: true)); + var output = console.Output; + + Assert.DoesNotContain("Complexity", output); + } + private static AnalysisSummary BuildSummary() { var file = new FileAnalysis diff --git a/tests/Sloc.Cli.Tests/UpdateCheckerTests.cs b/tests/Sloc.Cli.Tests/UpdateCheckerTests.cs index 9d9dc0b..ea8ddb3 100644 --- a/tests/Sloc.Cli.Tests/UpdateCheckerTests.cs +++ b/tests/Sloc.Cli.Tests/UpdateCheckerTests.cs @@ -1,3 +1,4 @@ +using Sloc.Cli.Updates; using System.Net; namespace Sloc.Cli.Tests; diff --git a/tests/Sloc.Core.Tests/DirectoryScannerTests.cs b/tests/Sloc.Core.Tests/DirectoryScannerTests.cs index 191792b..b7d9e4c 100644 --- a/tests/Sloc.Core.Tests/DirectoryScannerTests.cs +++ b/tests/Sloc.Core.Tests/DirectoryScannerTests.cs @@ -1,3 +1,5 @@ +using Sloc.Core.Scanning; + namespace Sloc.Core.Tests; /// diff --git a/tests/Sloc.Core.Tests/FileAnalyzerComplexityTests.cs b/tests/Sloc.Core.Tests/FileAnalyzerComplexityTests.cs new file mode 100644 index 0000000..7909489 --- /dev/null +++ b/tests/Sloc.Core.Tests/FileAnalyzerComplexityTests.cs @@ -0,0 +1,80 @@ +using Sloc.Core.Languages; + +namespace Sloc.Core.Tests; + +/// +/// Contains unit tests for the simplified cyclomatic-complexity metric computed by +/// . +/// +public class FileAnalyzerComplexityTests +{ + private static readonly LanguageDefinition CSharp = Resolve(".cs"); + private static readonly LanguageDefinition Yaml = Resolve(".yml"); + + /// + /// Verifies that a file with no branch points has the baseline complexity of 1. + /// + [Fact] + public void AnalyzeText_NoBranches_ReturnsBaselineComplexityOfOne() + { + const string content = "int x = 1;\nint y = 2;\n"; + + var result = new FileAnalyzer().AnalyzeText(content, CSharp); + + Assert.Equal(1, result.Complexity); + } + + /// + /// Verifies that each branch-point token (if/for/while/&&) increments complexity + /// by one on top of the baseline of 1. + /// + [Fact] + public void AnalyzeText_MultipleBranches_CountsEachBranchToken() + { + const string content = + "if (a) { }\n" + // +1 (if) + "for (int i = 0; i < 10; i++) { }\n" + // +1 (for) + "while (b) { }\n" + // +1 (while) + "if (a && b) { }\n"; // +1 (if) +1 (&&) + + var result = new FileAnalyzer().AnalyzeText(content, CSharp); + + Assert.Equal(6, result.Complexity); + } + + /// + /// Verifies that branch tokens inside comments are not counted, since only code lines + /// contribute to complexity. + /// + [Fact] + public void AnalyzeText_BranchTokenInComment_IsNotCounted() + { + const string content = + "// if (a) for (b) while (c)\n" + + "int x = 1;\n"; + + var result = new FileAnalyzer().AnalyzeText(content, CSharp); + + Assert.Equal(1, result.Complexity); + } + + /// + /// Verifies that a language which does not support complexity analysis (e.g. YAML) + /// always yields a complexity, regardless of content. + /// + [Fact] + public void AnalyzeText_UnsupportedLanguage_ReturnsNullComplexity() + { + const string content = "key: value\nother: 1\n"; + + var result = new FileAnalyzer().AnalyzeText(content, Yaml); + + Assert.Null(result.Complexity); + } + + private static LanguageDefinition Resolve(string extension) + { + LanguageRegistry.TryGetByExtension(extension, out var language); + return language!; + } +} diff --git a/tests/Sloc.Core.Tests/GitAttributesRulesTests.cs b/tests/Sloc.Core.Tests/GitAttributesRulesTests.cs index 03426b9..2f25661 100644 --- a/tests/Sloc.Core.Tests/GitAttributesRulesTests.cs +++ b/tests/Sloc.Core.Tests/GitAttributesRulesTests.cs @@ -1,3 +1,5 @@ +using Sloc.Core.Scanning; + namespace Sloc.Core.Tests; /// diff --git a/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs b/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs index e3447c3..a46966b 100644 --- a/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs +++ b/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs @@ -1,3 +1,5 @@ +using Sloc.Core.Scanning; + namespace Sloc.Core.Tests; /// diff --git a/tests/Sloc.Core.Tests/LanguageRegistryComplexityTests.cs b/tests/Sloc.Core.Tests/LanguageRegistryComplexityTests.cs new file mode 100644 index 0000000..ac48b53 --- /dev/null +++ b/tests/Sloc.Core.Tests/LanguageRegistryComplexityTests.cs @@ -0,0 +1,64 @@ +using Sloc.Core.Languages; + +namespace Sloc.Core.Tests; + +/// +/// Contains unit tests for and +/// . +/// +public class LanguageRegistryComplexityTests +{ + /// + /// Verifies that mainstream C-style languages with a populated + /// list are reported as supported. + /// + /// The language display name. + [Theory] + [InlineData("C#")] + [InlineData("Java")] + [InlineData("JavaScript")] + [InlineData("TypeScript")] + [InlineData("Python")] + [InlineData("Go")] + [InlineData("Rust")] + public void SupportsComplexity_MainstreamLanguage_ReturnsTrue(string name) + { + Assert.True(LanguageRegistry.SupportsComplexity(name)); + } + + /// + /// Verifies that a language with no + /// entries (e.g. a markup/data language) is reported as unsupported. + /// + [Fact] + public void SupportsComplexity_UnsupportedLanguage_ReturnsFalse() + { + Assert.False(LanguageRegistry.SupportsComplexity("YAML")); + Assert.False(LanguageRegistry.SupportsComplexity("JSON")); + } + + /// + /// Verifies that an unknown or language name is treated as + /// unsupported rather than throwing. + /// + [Fact] + public void SupportsComplexity_UnknownOrNullName_ReturnsFalse() + { + Assert.False(LanguageRegistry.SupportsComplexity("Nonexistent")); + Assert.False(LanguageRegistry.SupportsComplexity(null)); + } + + /// + /// Verifies that matches the + /// presence of at least one keyword directly on the definition. + /// + [Fact] + public void SupportsComplexity_Definition_MatchesKeywordPresence() + { + LanguageRegistry.TryGetByExtension(".cs", out var csharp); + LanguageRegistry.TryGetByExtension(".json", out var json); + + Assert.True(csharp!.SupportsComplexity); + Assert.False(json!.SupportsComplexity); + } +}