From 41d03cb12f0c95aae80b04463b0a1eb1dd6fdb87 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Thu, 20 Aug 2026 16:37:58 +0800 Subject: [PATCH] Update output renderers and analyze handler --- README.md | 20 +- src/Sloc.Cli/AnalyzeHandler.cs | 647 ++++++++++--------- src/Sloc.Cli/Output/CsvRenderer.cs | 111 ++-- src/Sloc.Cli/Output/HtmlRenderer.cs | 85 +-- src/Sloc.Cli/Output/IResultRenderer.cs | 7 +- src/Sloc.Cli/Output/JsonRenderer.cs | 201 ++++-- src/Sloc.Cli/Output/MarkdownRenderer.cs | 35 +- src/Sloc.Cli/Output/TableRenderer.cs | 6 +- src/Sloc.Core/Languages/LanguageRegistry.cs | 6 +- tests/Sloc.Cli.Tests/JsonRendererTests.cs | 97 +-- tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs | 5 +- 11 files changed, 701 insertions(+), 519 deletions(-) diff --git a/README.md b/README.md index 60f4c9e..c706348 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@  # Sloc -[English](#english) | [中文](#中文) - [![CI](https://github.com/coldhighsun/Sloc/actions/workflows/ci.yml/badge.svg)](https://github.com/coldhighsun/Sloc/actions/workflows/ci.yml) [![NuGet Version](https://img.shields.io/nuget/v/Sloc)](https://www.nuget.org/packages/Sloc) [![NuGet Downloads](https://img.shields.io/nuget/dt/Sloc?label=nuget%20downloads)](https://www.nuget.org/packages/Sloc) [![GitHub All Releases](https://img.shields.io/github/downloads/coldhighsun/Sloc/total?label=release%20downloads)](https://github.com/coldhighsun/Sloc/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +![.NET](https://img.shields.io/badge/.NET-8.0-512BD4) +[![GitHub release](https://img.shields.io/github/v/release/coldhighsun/Sloc)](https://github.com/coldhighsun/Sloc/releases/latest) +![Winget](https://img.shields.io/winget/v/coldhighsun.sloc) +![GitHub last commit](https://img.shields.io/github/last-commit/coldhighsun/Sloc) + +--- + +[English](#english) | [中文](#中文) -Sloc (**S**ource **L**ines **O**f **C**ode) is a .NET global command-line tool for counting lines of source code. It analyzes files individually, distinguishing **code lines**, **comment lines**, and **blank lines**, then aggregates results by programming language. It supports 66 auto-detected languages, five output formats (table, JSON, HTML, CSV, Markdown), per-file detail view, and comment health indicators. +Sloc (**S**ource **L**ines **O**f **C**ode) is a .NET global command-line tool for counting lines of source code. ## Features @@ -218,13 +224,7 @@ This project is released under the [MIT License](https://opensource.org/licenses [English](#english) | [中文](#中文) -[![CI](https://github.com/coldhighsun/Sloc/actions/workflows/ci.yml/badge.svg)](https://github.com/coldhighsun/Sloc/actions/workflows/ci.yml) -[![NuGet Version](https://img.shields.io/nuget/v/Sloc)](https://www.nuget.org/packages/Sloc) -[![NuGet Downloads](https://img.shields.io/nuget/dt/Sloc)](https://www.nuget.org/packages/Sloc) -[![GitHub All Releases](https://img.shields.io/github/downloads/coldhighsun/Sloc/total?label=release%20downloads)](https://github.com/coldhighsun/Sloc/releases) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) - -Sloc(**S**ource **L**ines **O**f **C**ode)是一个用于统计源代码行数的 .NET 全局命令行工具。它会逐文件分析代码,区分**代码行**、**注释行**和**空行**,并按编程语言进行聚合汇总,支持 66 种语言自动识别、五种输出格式(表格、JSON、HTML、CSV、Markdown)、逐文件明细视图以及注释健康度指标。 +Sloc(**S**ource **L**ines **O**f **C**ode)是一个用于统计源代码行数的 .NET 全局命令行工具。 ## 功能特性 diff --git a/src/Sloc.Cli/AnalyzeHandler.cs b/src/Sloc.Cli/AnalyzeHandler.cs index 9b69e11..cb80803 100644 --- a/src/Sloc.Cli/AnalyzeHandler.cs +++ b/src/Sloc.Cli/AnalyzeHandler.cs @@ -122,6 +122,16 @@ 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. @@ -151,16 +161,6 @@ public int? Jobs 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; - } - /// /// When set, a file listing paths (one per line) to analyze directly instead of /// scanning . - reads the list from stdin. @@ -310,12 +310,26 @@ public int Execute(AnalyzeOptions options) .GetCustomAttribute() ?.InformationalVersion; + // The path/list-file/git-hash the current run analyzed, surfaced in report + // metadata (Table banner, Json/Html/Markdown) so a saved or shared report can be + // traced back to its source. The scanned directory/file is always resolved to a + // full absolute path so the report is unambiguous regardless of the working + // directory it was generated from (e.g. "." becomes "C:\repo"). + var resolvedPath = ResolveFullPath(options.Path); + var sourcePath = options.GitHash is { } gitHashForDisplay + ? $"{resolvedPath} @ {gitHashForDisplay}" + : options.ListFile is { } listFileForDisplay + ? $"list: {ResolveFullPath(listFileForDisplay)}" + : resolvedPath; + if (options.Format == OutputFormat.Table && !Console.IsOutputRedirected && !options.Quiet) { if (!string.IsNullOrEmpty(version)) { AnsiConsole.MarkupLine($"[grey]sloc {Markup.Escape(version)}[/]"); } + + AnsiConsole.MarkupLine($"[grey]Analyzing: {Markup.Escape(sourcePath)}[/]"); } // Start the update check concurrently with the scan/analysis so a slow network @@ -365,317 +379,317 @@ public int Execute(AnalyzeOptions options) try { - ScanResult scanResult; - try - { - if (gitSnapshot is not null) - { - scanResult = _scanner.ScanFiles(gitSnapshot.Files.Select(f => f.TempPath), scanOptions); - } - else if (options.ListFile is { } listFile) - { - scanResult = _scanner.ScanFiles(ReadListFile(listFile), scanOptions); - } - else if (showProgress) + ScanResult scanResult; + try { - ScanResult? result = null; - var refreshTimer = Stopwatch.StartNew(); - var gitignoreScanLabel = (scanOptions.RespectGitignore, scanOptions.RespectGitAttributes) switch + if (gitSnapshot is not null) { - (true, true) => "checking .gitignore/.gitattributes", - (true, false) => "checking .gitignore", - (false, true) => "checking .gitattributes", - (false, false) => "walking directories", - }; - - AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .Start("Scanning files...", ctx => + scanResult = _scanner.ScanFiles(gitSnapshot.Files.Select(f => f.TempPath), scanOptions); + } + else if (options.ListFile is { } listFile) + { + scanResult = _scanner.ScanFiles(ReadListFile(listFile), scanOptions); + } + else if (showProgress) + { + ScanResult? result = null; + var refreshTimer = Stopwatch.StartNew(); + var gitignoreScanLabel = (scanOptions.RespectGitignore, scanOptions.RespectGitAttributes) switch { - result = _scanner.Scan( - options.Path, - scanOptions, - onFileFound: (count, path) => - { - if (refreshTimer.Elapsed < ScanStatusRefreshInterval) + (true, true) => "checking .gitignore/.gitattributes", + (true, false) => "checking .gitignore", + (false, true) => "checking .gitattributes", + (false, false) => "walking directories", + }; + + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start("Scanning files...", ctx => + { + result = _scanner.Scan( + options.Path, + scanOptions, + onFileFound: (count, path) => { - return; - } - - refreshTimer.Restart(); - ctx.Status($"Scanning... [green]{count:N0}[/] files ([grey]{Markup.Escape(Path.GetFileName(path))}[/])"); - }, - onGitignoreScan: (count, path) => - { - if (refreshTimer.Elapsed < ScanStatusRefreshInterval) + if (refreshTimer.Elapsed < ScanStatusRefreshInterval) + { + return; + } + + refreshTimer.Restart(); + ctx.Status($"Scanning... [green]{count:N0}[/] files ([grey]{Markup.Escape(Path.GetFileName(path))}[/])"); + }, + onGitignoreScan: (count, path) => { - return; - } - - refreshTimer.Restart(); - ctx.Status($"Scanning... {gitignoreScanLabel} ([green]{count:N0}[/] dirs, [grey]{Markup.Escape(Path.GetFileName(path))}[/])"); - }); - }); - scanResult = result ?? throw new InvalidOperationException("Scan did not complete."); + if (refreshTimer.Elapsed < ScanStatusRefreshInterval) + { + return; + } + + refreshTimer.Restart(); + ctx.Status($"Scanning... {gitignoreScanLabel} ([green]{count:N0}[/] dirs, [grey]{Markup.Escape(Path.GetFileName(path))}[/])"); + }); + }); + scanResult = result ?? throw new InvalidOperationException("Scan did not complete."); + } + else + { + scanResult = _scanner.Scan(options.Path, scanOptions); + } } - else + catch (Exception ex) when (ex is DirectoryNotFoundException or UnauthorizedAccessException or IOException) { - scanResult = _scanner.Scan(options.Path, scanOptions); + Console.Error.WriteLine(ex.Message); + return ExitCode.Error; } - } - catch (Exception ex) when (ex is DirectoryNotFoundException or UnauthorizedAccessException or IOException) - { - Console.Error.WriteLine(ex.Message); - return ExitCode.Error; - } - var files = scanResult.Files; - var skipped = new List(scanResult.Skipped); + var files = scanResult.Files; + var skipped = new List(scanResult.Skipped); - // Analyze into fixed slots so the merged order is deterministic (scan order), - // independent of the degree of parallelism. - var analyses = new FileAnalysis?[files.Count]; - var fileSkips = new SkippedEntry?[files.Count]; - var aggregator = new LiveAggregator(options.Sort, options.Top); + // Analyze into fixed slots so the merged order is deterministic (scan order), + // independent of the degree of parallelism. + var analyses = new FileAnalysis?[files.Count]; + var fileSkips = new SkippedEntry?[files.Count]; + var aggregator = new LiveAggregator(options.Sort, options.Top); - void AnalyzeAt(int i) - { - try - { - var analysis = _analyzer.Analyze(files[i].Path, files[i].Language, computeHash: options.Unique); - analyses[i] = analysis; - aggregator.Add(analysis); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or BinaryFileException) + void AnalyzeAt(int i) { - fileSkips[i] = new SkippedEntry(files[i].Path, ex.Message); - } - catch (Exception ex) when (ex is not OutOfMemoryException and not OperationCanceledException) - { - // Any other per-file failure (e.g. a decoding error) skips just that file - // rather than aborting the whole run; fatal conditions are left to propagate. - fileSkips[i] = new SkippedEntry(files[i].Path, $"analysis error: {ex.Message}"); - } - } - - var jobs = options.Jobs is { } requested && requested > 0 ? requested : Environment.ProcessorCount; - var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = jobs }; - - if (files.Count == 0) - { - // Nothing to analyze. - } - else if (!showProgress) - { - Parallel.For(0, files.Count, parallelOptions, AnalyzeAt); - } - else if (options is { Format: OutputFormat.Table, ByFile: false, BaselinePath: null }) - { - AnsiConsole.Live(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth)) - .AutoClear(false) - .Start(ctx => + try { - // Analyze on a background task while this thread refreshes the table - // from the thread-safe aggregator (no per-tick re-aggregation). - var work = Task.Run(() => Parallel.For(0, files.Count, parallelOptions, AnalyzeAt)); - while (!work.IsCompleted) - { - ctx.UpdateTarget(tableRenderer.BuildLanguageTable( - aggregator.ToSummary(), - $"[grey]Analyzing... {aggregator.FilesProcessed:N0} / {files.Count:N0}[/]", - noHealth: options.NoHealth)); - Thread.Sleep(LiveTableRefreshInterval); - } - - work.GetAwaiter().GetResult(); - ctx.UpdateTarget(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth)); - }); - } - else - { - AnsiConsole.Progress() - .AutoClear(true) - .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), new SpinnerColumn()) - .Start(ctx => + var analysis = _analyzer.Analyze(files[i].Path, files[i].Language, computeHash: options.Unique); + analyses[i] = analysis; + aggregator.Add(analysis); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or BinaryFileException) { - var task = ctx.AddTask("[green]Analyzing[/]", maxValue: files.Count); - var work = Task.Run(() => Parallel.For(0, files.Count, parallelOptions, i => - { - AnalyzeAt(i); - task.Increment(1); - })); - work.GetAwaiter().GetResult(); - }); - } + fileSkips[i] = new SkippedEntry(files[i].Path, ex.Message); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not OperationCanceledException) + { + // Any other per-file failure (e.g. a decoding error) skips just that file + // rather than aborting the whole run; fatal conditions are left to propagate. + fileSkips[i] = new SkippedEntry(files[i].Path, $"analysis error: {ex.Message}"); + } + } - // Merge in scan order so results are deterministic regardless of --jobs. - var results = new List(files.Count); - foreach (var analysis in analyses) - { - if (analysis is not null) + var jobs = options.Jobs is { } requested && requested > 0 ? requested : Environment.ProcessorCount; + var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = jobs }; + + if (files.Count == 0) { - results.Add(analysis); + // Nothing to analyze. } - } - - foreach (var fileSkip in fileSkips) - { - if (fileSkip is not null) + else if (!showProgress) { - skipped.Add(fileSkip); + Parallel.For(0, files.Count, parallelOptions, AnalyzeAt); } - } - - if (options.Unique) - { - results = DeduplicateByHash(results, skipped); - } - - if (gitSnapshot is not null) - { - var gitPathByTempPath = gitSnapshot.Files.ToDictionary(f => f.TempPath, f => f.GitPath); - results = RemapGitPaths(results, gitPathByTempPath); - skipped = RemapGitPaths(skipped, gitPathByTempPath); - skipped.AddRange(gitSnapshot.Skipped); - } - - var summary = new AnalysisSummary( - results, - skipped, - options.Sort, - descending: options.Sort != LanguageSort.Name, - top: options.Top); - - if (options.BaselinePath is { } baselinePath) - { - JsonReport baseline; - try + else if (options is { Format: OutputFormat.Table, ByFile: false, BaselinePath: null }) { - baseline = DiffRenderer.Load(baselinePath); + AnsiConsole.Live(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth)) + .AutoClear(false) + .Start(ctx => + { + // Analyze on a background task while this thread refreshes the table + // from the thread-safe aggregator (no per-tick re-aggregation). + var work = Task.Run(() => Parallel.For(0, files.Count, parallelOptions, AnalyzeAt)); + while (!work.IsCompleted) + { + ctx.UpdateTarget(tableRenderer.BuildLanguageTable( + aggregator.ToSummary(), + $"[grey]Analyzing... {aggregator.FilesProcessed:N0} / {files.Count:N0}[/]", + noHealth: options.NoHealth)); + Thread.Sleep(LiveTableRefreshInterval); + } + + work.GetAwaiter().GetResult(); + ctx.UpdateTarget(tableRenderer.BuildLanguageTable(aggregator.ToSummary(), noHealth: options.NoHealth)); + }); } - catch (InvalidOperationException ex) + else { - Console.Error.WriteLine($"sloc: {ex.Message}"); - return ExitCode.Error; + AnsiConsole.Progress() + .AutoClear(true) + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), new SpinnerColumn()) + .Start(ctx => + { + var task = ctx.AddTask("[green]Analyzing[/]", maxValue: files.Count); + var work = Task.Run(() => Parallel.For(0, files.Count, parallelOptions, i => + { + AnalyzeAt(i); + task.Increment(1); + })); + work.GetAwaiter().GetResult(); + }); } - // Baseline diffs are only rendered as a console Table or as JSON. Any other - // requested format (and any --output for a non-JSON diff) is not supported, so - // warn and fall back to the Table rather than silently ignoring the request. - if (options.Format is not (OutputFormat.Json or OutputFormat.Table)) + // Merge in scan order so results are deterministic regardless of --jobs. + var results = new List(files.Count); + foreach (var analysis in analyses) { - if (options.OutputFile is not null && options.OutputFile != StdoutToken) + if (analysis is not null) { - Console.Error.WriteLine( - $"sloc: --baseline diff output is only supported for Table and Json formats; -f {options.Format.ToString().ToLowerInvariant()} with -o '{options.OutputFile}' cannot be honored."); - return ExitCode.Error; + results.Add(analysis); } - - Console.Error.WriteLine( - $"sloc: --baseline diff output is only supported for Table and Json formats; ignoring -f {options.Format.ToString().ToLowerInvariant()} and rendering a table."); } - if (options.Format == OutputFormat.Json && (options.OutputFile is null || options.OutputFile == StdoutToken)) + foreach (var fileSkip in fileSkips) { - DiffRenderer.RenderJson(Console.Out, summary, baseline); - } - else if (options.Format == OutputFormat.Json) - { - if (!WriteToFile(options.OutputFile!, writer => DiffRenderer.RenderJson(writer, summary, baseline), options.Quiet)) + if (fileSkip is not null) { - return ExitCode.Error; + skipped.Add(fileSkip); } } - else + + if (options.Unique) { - DiffRenderer.RenderTable(summary, baseline); + results = DeduplicateByHash(results, skipped); } - ReportUpdate(updateCheck, version); - return ThresholdResult(options, summary); - } - - if (options.Format == OutputFormat.Json) - { - // JSON defaults to stdout (pipeable); an explicit path writes a file. - if (options.OutputFile is null || options.OutputFile == StdoutToken) + if (gitSnapshot is not null) { - new JsonRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed); + var gitPathByTempPath = gitSnapshot.Files.ToDictionary(f => f.TempPath, f => f.GitPath); + results = RemapGitPaths(results, gitPathByTempPath); + skipped = RemapGitPaths(skipped, gitPathByTempPath); + skipped.AddRange(gitSnapshot.Skipped); } - else + + var summary = new AnalysisSummary( + results, + skipped, + options.Sort, + descending: options.Sort != LanguageSort.Name, + top: options.Top); + + if (options.BaselinePath is { } baselinePath) { - if (!WriteToFile(options.OutputFile, writer => new JsonRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed), options.Quiet)) + JsonReport baseline; + try + { + baseline = DiffRenderer.Load(baselinePath); + } + catch (InvalidOperationException ex) { + Console.Error.WriteLine($"sloc: {ex.Message}"); return ExitCode.Error; } + + // Baseline diffs are only rendered as a console Table or as JSON. Any other + // requested format (and any --output for a non-JSON diff) is not supported, so + // warn and fall back to the Table rather than silently ignoring the request. + if (options.Format is not (OutputFormat.Json or OutputFormat.Table)) + { + if (options.OutputFile is not null && options.OutputFile != StdoutToken) + { + Console.Error.WriteLine( + $"sloc: --baseline diff output is only supported for Table and Json formats; -f {options.Format.ToString().ToLowerInvariant()} with -o '{options.OutputFile}' cannot be honored."); + return ExitCode.Error; + } + + Console.Error.WriteLine( + $"sloc: --baseline diff output is only supported for Table and Json formats; ignoring -f {options.Format.ToString().ToLowerInvariant()} and rendering a table."); + } + + if (options.Format == OutputFormat.Json && (options.OutputFile is null || options.OutputFile == StdoutToken)) + { + DiffRenderer.RenderJson(Console.Out, summary, baseline); + } + else if (options.Format == OutputFormat.Json) + { + if (!WriteToFile(options.OutputFile!, writer => DiffRenderer.RenderJson(writer, summary, baseline), options.Quiet)) + { + return ExitCode.Error; + } + } + else + { + DiffRenderer.RenderTable(summary, baseline); + } + + ReportUpdate(updateCheck, version); + return ThresholdResult(options, summary); } - } - else if (options.Format == OutputFormat.Html) - { - // 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); - } - else + + if (options.Format == OutputFormat.Json) { - if (!WriteToFile(options.OutputFile, writer => new HtmlRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed), options.Quiet)) + // JSON defaults to stdout (pipeable); an explicit path writes a file. + if (options.OutputFile is null || options.OutputFile == StdoutToken) { - return ExitCode.Error; + new JsonRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath); + } + else + { + if (!WriteToFile(options.OutputFile, writer => new JsonRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + { + return ExitCode.Error; + } } } - } - else if (options.Format == OutputFormat.Csv) - { - // CSV defaults to stdout (pipeable); an explicit path writes a file. - if (options.OutputFile is null || options.OutputFile == StdoutToken) + else if (options.Format == OutputFormat.Html) { - new CsvRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed); + // 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); + } + else + { + if (!WriteToFile(options.OutputFile, writer => new HtmlRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + { + return ExitCode.Error; + } + } } - else + else if (options.Format == OutputFormat.Csv) { - if (!WriteToFile(options.OutputFile, writer => new CsvRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed), options.Quiet)) + // CSV defaults to stdout (pipeable); an explicit path writes a file. + if (options.OutputFile is null || options.OutputFile == StdoutToken) { - return ExitCode.Error; + new CsvRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath); + } + else + { + if (!WriteToFile(options.OutputFile, writer => new CsvRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + { + return ExitCode.Error; + } } } - } - else if (options.Format == OutputFormat.Markdown) - { - // Markdown defaults to stdout (pasteable); an explicit path writes a file. - if (options.OutputFile is null || options.OutputFile == StdoutToken) + else if (options.Format == OutputFormat.Markdown) { - new MarkdownRenderer(Console.Out).Render(summary, options.ByFile, options.NoHealth, options.Detailed); + // 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); + } + else + { + if (!WriteToFile(options.OutputFile, writer => new MarkdownRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed, sourcePath), options.Quiet)) + { + return ExitCode.Error; + } + } } else { - if (!WriteToFile(options.OutputFile, writer => new MarkdownRenderer(writer).Render(summary, options.ByFile, options.NoHealth, options.Detailed), options.Quiet)) + if (summary.FileCount == 0) { - return ExitCode.Error; + AnsiConsole.MarkupLine("[yellow]No files matched.[/]"); + } + else if (options.ByFile) + { + tableRenderer.RenderByFile(summary, options.NoHealth, options.Paged); + } + else if (!showProgress) + { + // The live table only renders during progress; render it here otherwise. + AnsiConsole.Write(tableRenderer.BuildLanguageTable(summary, noHealth: options.NoHealth)); } - } - } - else - { - if (summary.FileCount == 0) - { - AnsiConsole.MarkupLine("[yellow]No files matched.[/]"); - } - else if (options.ByFile) - { - tableRenderer.RenderByFile(summary, options.NoHealth, options.Paged); - } - else if (!showProgress) - { - // The live table only renders during progress; render it here otherwise. - AnsiConsole.Write(tableRenderer.BuildLanguageTable(summary, noHealth: options.NoHealth)); - } - tableRenderer.RenderSkipped(summary); - } + tableRenderer.RenderSkipped(summary); + } - ReportUpdate(updateCheck, version); - return ThresholdResult(options, summary); + ReportUpdate(updateCheck, version); + return ThresholdResult(options, summary); } finally { @@ -683,49 +697,6 @@ void AnalyzeAt(int i) } } - /// - /// Replaces each analysis's temporary extraction path with its original git-relative - /// path, so downstream rendering and --baseline diffing never see a temp path. - /// - private static List RemapGitPaths(List analyses, Dictionary gitPathByTempPath) - { - for (var i = 0; i < analyses.Count; i++) - { - if (gitPathByTempPath.TryGetValue(analyses[i].Path, out var gitPath)) - { - var analysis = analyses[i]; - analyses[i] = new FileAnalysis - { - Path = gitPath, - Language = analysis.Language, - Code = analysis.Code, - Comment = analysis.Comment, - Blank = analysis.Blank, - Hash = analysis.Hash - }; - } - } - - return analyses; - } - - /// - /// Replaces each skipped entry's temporary extraction path with its original - /// git-relative path. - /// - private static List RemapGitPaths(List skipped, Dictionary gitPathByTempPath) - { - for (var i = 0; i < skipped.Count; i++) - { - if (gitPathByTempPath.TryGetValue(skipped[i].Path, out var gitPath)) - { - skipped[i] = skipped[i] with { Path = gitPath }; - } - } - - return skipped; - } - /// /// Keeps only the first file (in scan order) for each distinct content hash; every /// later duplicate is removed from and added to @@ -772,6 +743,52 @@ private static IEnumerable ReadListFile(string listFile) return lines.Where(line => !string.IsNullOrWhiteSpace(line)); } + /// + /// Replaces each analysis's temporary extraction path with its original git-relative + /// path, so downstream rendering and --baseline diffing never see a temp path. + /// + private static List RemapGitPaths(List analyses, Dictionary gitPathByTempPath) + { + for (var i = 0; i < analyses.Count; i++) + { + if (gitPathByTempPath.TryGetValue(analyses[i].Path, out var gitPath)) + { + var analysis = analyses[i]; + analyses[i] = new FileAnalysis + { + Path = gitPath, + Language = analysis.Language, + Code = analysis.Code, + Comment = analysis.Comment, + Blank = analysis.Blank, + Hash = analysis.Hash + }; + } + } + + return analyses; + } + + /// + /// Replaces each skipped entry's temporary extraction path with its original + /// git-relative path. + /// + private static List RemapGitPaths(List skipped, Dictionary gitPathByTempPath) + { + for (var i = 0; i < skipped.Count; i++) + { + if (gitPathByTempPath.TryGetValue(skipped[i].Path, out var gitPath)) + { + skipped[i] = skipped[i] with + { + Path = gitPath + }; + } + } + + return skipped; + } + private static void ReportUpdate(Task? updateCheck, string? currentVersion) { if (updateCheck is null || string.IsNullOrEmpty(currentVersion)) @@ -795,6 +812,30 @@ private static void ReportUpdate(Task? updateCheck, string? } } + /// + /// Resolves to a full absolute path for display purposes (e.g. + /// the "Analyzing:" banner and report metadata), so a relative input like "." is shown + /// unambiguously. The stdin sentinel ("-") is returned unchanged since it isn't a + /// filesystem path. Falls back to the original value if it cannot be resolved (e.g. + /// invalid path characters), since this is display-only and must never fail the run. + /// + private static string ResolveFullPath(string path) + { + if (path == StdoutToken) + { + return path; + } + + try + { + return Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return path; + } + } + private static int ThresholdResult(AnalyzeOptions options, AnalysisSummary summary) { if (options.MinCommentPct is { } min && summary.FileCount > 0 && summary.CommentPct < min) diff --git a/src/Sloc.Cli/Output/CsvRenderer.cs b/src/Sloc.Cli/Output/CsvRenderer.cs index 4a3bc82..904de8c 100644 --- a/src/Sloc.Cli/Output/CsvRenderer.cs +++ b/src/Sloc.Cli/Output/CsvRenderer.cs @@ -31,7 +31,12 @@ public CsvRenderer(TextWriter? writer = null) } /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false) + /// + /// is accepted only to satisfy + /// 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) { ArgumentNullException.ThrowIfNull(summary); @@ -52,20 +57,22 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det } } - private void RenderSkipped(AnalysisSummary summary) + private static string Escape(string field) { - _writer.Write("\r\n"); - WriteRow(["Path", "Reason"]); - - foreach (var entry in summary.Skipped) + if (field.IndexOfAny([',', '"', '\r', '\n']) < 0) { - WriteRow([entry.Path, entry.Reason]); + return field; } + + return "\"" + field.Replace("\"", "\"\"") + "\""; } - private void RenderByLanguage(AnalysisSummary summary, bool noHealth) + private static string HealthCell(CommentHealthLevel health) => + health == CommentHealthLevel.NotApplicable ? string.Empty : health.ToString(); + + private void RenderByFile(AnalysisSummary summary, bool noHealth) { - var header = new List { "Language", "Files", "Code", "Comment", "Blank", "Total" }; + var header = new List { "Path", "Language", "Code", "Comment", "Blank", "Total" }; if (!noHealth) { header.Add("Health"); @@ -73,31 +80,31 @@ private void RenderByLanguage(AnalysisSummary summary, bool noHealth) WriteRow(header); - foreach (var language in summary.ByLanguage) + foreach (var file in summary.Files) { var row = new List { - language.Language, - language.Files.ToString(), - language.Code.ToString(), - language.Comment.ToString(), - language.Blank.ToString(), - language.Total.ToString() + file.Path, + file.Language, + file.Code.ToString(), + file.Comment.ToString(), + file.Blank.ToString(), + file.Total.ToString() }; if (!noHealth) { - row.Add(HealthCell(language.Health)); + row.Add(HealthCell(file.Health)); } WriteRow(row); } - WriteTotalRow(summary, noHealth, summary.FileCount.ToString()); + WriteTotalRow(summary, noHealth, string.Empty); } - private void RenderByFile(AnalysisSummary summary, bool noHealth) + private void RenderByLanguage(AnalysisSummary summary, bool noHealth) { - var header = new List { "Path", "Language", "Code", "Comment", "Blank", "Total" }; + var header = new List { "Language", "Files", "Code", "Comment", "Blank", "Total" }; if (!noHealth) { header.Add("Health"); @@ -105,52 +112,39 @@ private void RenderByFile(AnalysisSummary summary, bool noHealth) WriteRow(header); - foreach (var file in summary.Files) + foreach (var language in summary.ByLanguage) { var row = new List { - file.Path, - file.Language, - file.Code.ToString(), - file.Comment.ToString(), - file.Blank.ToString(), - file.Total.ToString() + language.Language, + language.Files.ToString(), + language.Code.ToString(), + language.Comment.ToString(), + language.Blank.ToString(), + language.Total.ToString() }; if (!noHealth) { - row.Add(HealthCell(file.Health)); + row.Add(HealthCell(language.Health)); } WriteRow(row); } - WriteTotalRow(summary, noHealth, string.Empty); + WriteTotalRow(summary, noHealth, summary.FileCount.ToString()); } - // 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 RenderSkipped(AnalysisSummary summary) { - var row = new List - { - "Total", - secondColumn, - summary.Code.ToString(), - summary.Comment.ToString(), - summary.Blank.ToString(), - summary.Total.ToString() - }; - if (!noHealth) + _writer.Write("\r\n"); + WriteRow(["Path", "Reason"]); + + foreach (var entry in summary.Skipped) { - row.Add(string.Empty); + WriteRow([entry.Path, entry.Reason]); } - - WriteRow(row); } - private static string HealthCell(CommentHealthLevel health) => - health == CommentHealthLevel.NotApplicable ? string.Empty : health.ToString(); - private void WriteRow(IReadOnlyList fields) { var sb = new StringBuilder(); @@ -169,13 +163,24 @@ private void WriteRow(IReadOnlyList fields) _writer.Write("\r\n"); } - private static string Escape(string field) + // 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) { - if (field.IndexOfAny([',', '"', '\r', '\n']) < 0) + var row = new List { - return field; + "Total", + secondColumn, + summary.Code.ToString(), + summary.Comment.ToString(), + summary.Blank.ToString(), + summary.Total.ToString() + }; + if (!noHealth) + { + row.Add(string.Empty); } - return "\"" + field.Replace("\"", "\"\"") + "\""; + WriteRow(row); } -} +} \ No newline at end of file diff --git a/src/Sloc.Cli/Output/HtmlRenderer.cs b/src/Sloc.Cli/Output/HtmlRenderer.cs index e505ab3..b04a7c8 100644 --- a/src/Sloc.Cli/Output/HtmlRenderer.cs +++ b/src/Sloc.Cli/Output/HtmlRenderer.cs @@ -206,55 +206,15 @@ public HtmlRenderer(TextWriter? writer = null, DateTimeOffset? generatedAt = nul } /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) { ArgumentNullException.ThrowIfNull(summary); var sb = new StringBuilder(); - BuildDocument(sb, summary, byFile, noHealth, detailed); + BuildDocument(sb, summary, byFile, noHealth, detailed, sourcePath); _writer.Write(sb); } - private void BuildDocument(StringBuilder sb, AnalysisSummary summary, bool byFile, bool noHealth, bool detailed) - { - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine($" {Encode("Sloc Report")}"); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine($"

{Encode("Sloc Report")}

"); - var generated = _generatedAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); - sb.AppendLine($"

{"Generated:"} {Encode(generated)}  |  {summary.FileCount:N0} {"files"}  |  {summary.Total:N0} {"total lines"}

"); - - if (detailed || !byFile) - { - BuildLanguageSection(sb, summary, noHealth); - } - - if (detailed || byFile) - { - BuildFileSection(sb, summary, noHealth); - } - - if (summary.Skipped.Count > 0) - { - BuildSkippedSection(sb, summary); - } - - sb.AppendLine(""); - - sb.AppendLine(""); - sb.AppendLine(""); - } - private static void BuildFileSection(StringBuilder sb, AnalysisSummary summary, bool noHealth) { sb.AppendLine($"

{Encode("By File")}

"); @@ -598,6 +558,47 @@ private static string ToRelative(string path) } } + private void BuildDocument(StringBuilder sb, AnalysisSummary summary, bool byFile, bool noHealth, bool detailed, string? sourcePath) + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine($" {Encode("Sloc Report")}"); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"

{Encode("Sloc Report")}

"); + var generated = _generatedAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); + var sourceMeta = string.IsNullOrEmpty(sourcePath) ? string.Empty : $"  |  {"Source:"} {Encode(sourcePath)}"; + sb.AppendLine($"

{"Generated:"} {Encode(generated)}  |  {summary.FileCount:N0} {"files"}  |  {summary.Total:N0} {"total lines"}{sourceMeta}

"); + + if (detailed || !byFile) + { + BuildLanguageSection(sb, summary, noHealth); + } + + if (detailed || byFile) + { + BuildFileSection(sb, summary, noHealth); + } + + if (summary.Skipped.Count > 0) + { + BuildSkippedSection(sb, summary); + } + + sb.AppendLine(""); + + sb.AppendLine(""); + sb.AppendLine(""); + } + private sealed class FileEntry { public required FileAnalysis File diff --git a/src/Sloc.Cli/Output/IResultRenderer.cs b/src/Sloc.Cli/Output/IResultRenderer.cs index b0b8ec2..0d16597 100644 --- a/src/Sloc.Cli/Output/IResultRenderer.cs +++ b/src/Sloc.Cli/Output/IResultRenderer.cs @@ -17,5 +17,10 @@ public interface IResultRenderer /// When , emit both the by-language summary and the per-file /// breakdown together instead of one or the other. /// - void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false); + /// + /// The path, list file, or git commit/tree-ish that was analyzed, included in report + /// 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); } \ No newline at end of file diff --git a/src/Sloc.Cli/Output/JsonRenderer.cs b/src/Sloc.Cli/Output/JsonRenderer.cs index 96d0d74..11b4f5f 100644 --- a/src/Sloc.Cli/Output/JsonRenderer.cs +++ b/src/Sloc.Cli/Output/JsonRenderer.cs @@ -9,8 +9,8 @@ namespace Sloc.Cli.Output; ///
public sealed class JsonRenderer : IResultRenderer { - private readonly TextWriter _writer; private readonly DateTimeOffset _generatedAt; + private readonly TextWriter _writer; /// /// Initializes a new instance of . @@ -30,7 +30,7 @@ public JsonRenderer(TextWriter? writer = null, DateTimeOffset? generatedAt = nul } /// - public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) { ArgumentNullException.ThrowIfNull(summary); @@ -40,6 +40,7 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det var report = new JsonReport { GeneratedAt = _generatedAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), + SourcePath = sourcePath, FileCount = summary.FileCount, Code = summary.Code, CodePct = Pct(summary.Code, summary.Total), @@ -86,41 +87,67 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det _writer.WriteLine(JsonSerializer.Serialize(report, SlocJsonContext.Default.JsonReport)); } - private static double Pct(int count, int total) => - total == 0 ? 0.0 : Math.Round((double)count / total * 100, 1); - private static string? Health(CommentHealthLevel health, bool noHealth) => noHealth || health == CommentHealthLevel.NotApplicable ? null : health.ToString(); + + private static double Pct(int count, int total) => + total == 0 ? 0.0 : Math.Round((double)count / total * 100, 1); } /// -/// The top-level JSON report payload. +/// Per-file statistics in the JSON report. /// -internal sealed class JsonReport +internal sealed class JsonFile { - public required string GeneratedAt { get; init; } - - public int FileCount { get; init; } - - public int Code { get; init; } + public int Blank + { + get; init; + } - public double? CodePct { get; init; } + public double? BlankPct + { + get; init; + } - public int Comment { get; init; } + public int Code + { + get; init; + } - public double? CommentPct { get; init; } + public double? CodePct + { + get; init; + } - public int Blank { get; init; } + public int Comment + { + get; init; + } - public double? BlankPct { get; init; } + public double? CommentPct + { + get; init; + } - public int Total { get; init; } + public string? Health + { + get; init; + } - public IReadOnlyList? ByLanguage { get; init; } + public required string Language + { + get; init; + } - public IReadOnlyList? Files { get; init; } + public required string Path + { + get; init; + } - public IReadOnlyList Skipped { get; init; } = []; + public int Total + { + get; init; + } } /// @@ -128,51 +155,127 @@ internal sealed class JsonReport /// internal sealed class JsonLanguage { - public required string Language { get; init; } + public int Blank + { + get; init; + } - public int Files { get; init; } + public double? BlankPct + { + get; init; + } - public int Code { get; init; } + public int Code + { + get; init; + } - public double? CodePct { get; init; } + public double? CodePct + { + get; init; + } - public int Comment { get; init; } + public int Comment + { + get; init; + } - public double? CommentPct { get; init; } + public double? CommentPct + { + get; init; + } - public int Blank { get; init; } + public int Files + { + get; init; + } - public double? BlankPct { get; init; } + public string? Health + { + get; init; + } - public int Total { get; init; } + public required string Language + { + get; init; + } - public string? Health { get; init; } + public int Total + { + get; init; + } } /// -/// Per-file statistics in the JSON report. +/// The top-level JSON report payload. /// -internal sealed class JsonFile +internal sealed class JsonReport { - public required string Path { get; init; } + public int Blank + { + get; init; + } + + public double? BlankPct + { + get; init; + } - public required string Language { get; init; } + public IReadOnlyList? ByLanguage + { + get; init; + } + + public int Code + { + get; init; + } + + public double? CodePct + { + get; init; + } - public int Code { get; init; } + public int Comment + { + get; init; + } - public double? CodePct { get; init; } + public double? CommentPct + { + get; init; + } - public int Comment { get; init; } + public int FileCount + { + get; init; + } - public double? CommentPct { get; init; } + public IReadOnlyList? Files + { + get; init; + } - public int Blank { get; init; } + public required string GeneratedAt + { + get; init; + } - public double? BlankPct { get; init; } + public IReadOnlyList Skipped { get; init; } = []; - public int Total { get; init; } + /// + /// The path, list file, or git commit/tree-ish that was analyzed. Omitted when not + /// supplied to the renderer. + /// + public string? SourcePath + { + get; init; + } - public string? Health { get; init; } + public int Total + { + get; init; + } } /// @@ -180,9 +283,15 @@ internal sealed class JsonFile /// internal sealed class JsonSkipped { - public required string Path { get; init; } + public required string Path + { + get; init; + } - public required string Reason { get; init; } + public required string Reason + { + get; init; + } } /// @@ -196,4 +305,4 @@ internal sealed class JsonSkipped [JsonSerializable(typeof(JsonReport))] internal sealed partial class SlocJsonContext : JsonSerializerContext { -} +} \ No newline at end of file diff --git a/src/Sloc.Cli/Output/MarkdownRenderer.cs b/src/Sloc.Cli/Output/MarkdownRenderer.cs index 8d343d3..2610cf6 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) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) { ArgumentNullException.ThrowIfNull(summary); @@ -40,7 +40,8 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det sb.AppendLine(); var generated = _generatedAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); - sb.AppendLine($"**Generated:** {generated} | **Files:** {summary.FileCount:N0} | **Total Lines:** {summary.Total:N0}"); + var sourceMeta = string.IsNullOrEmpty(sourcePath) ? string.Empty : $" | **Source:** {sourcePath}"; + sb.AppendLine($"**Generated:** {generated} | **Files:** {summary.FileCount:N0} | **Total Lines:** {summary.Total:N0}{sourceMeta}"); sb.AppendLine(); if (detailed || !byFile) @@ -74,21 +75,6 @@ public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool det _writer.Write(sb.ToString()); } - private static void AppendSkippedSection(StringBuilder sb, AnalysisSummary summary) - { - sb.AppendLine(); - sb.AppendLine("## Skipped"); - sb.AppendLine(); - - foreach (var entry in summary.Skipped) - { - sb.Append("- "); - sb.Append(Escape(entry.Path)); - sb.Append(" — "); - sb.AppendLine(Escape(entry.Reason)); - } - } - private static void AppendFileTable(StringBuilder sb, AnalysisSummary summary, bool noHealth) { var header = new List { "Path", "Language", "Code", "Comment", "Blank", "Total" }; @@ -195,6 +181,21 @@ private static void AppendRow(StringBuilder sb, IReadOnlyList cells) sb.AppendLine(" |"); } + private static void AppendSkippedSection(StringBuilder sb, AnalysisSummary summary) + { + sb.AppendLine(); + sb.AppendLine("## Skipped"); + sb.AppendLine(); + + foreach (var entry in summary.Skipped) + { + sb.Append("- "); + sb.Append(Escape(entry.Path)); + sb.Append(" — "); + sb.AppendLine(Escape(entry.Reason)); + } + } + private static string Escape(string cell) => cell.Replace("|", "\\|").Replace("\r", " ").Replace("\n", " "); diff --git a/src/Sloc.Cli/Output/TableRenderer.cs b/src/Sloc.Cli/Output/TableRenderer.cs index 542aeeb..6c19534 100644 --- a/src/Sloc.Cli/Output/TableRenderer.cs +++ b/src/Sloc.Cli/Output/TableRenderer.cs @@ -18,9 +18,11 @@ public sealed class TableRenderer : IResultRenderer /// 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. + /// renderers treat it as meaningless for this format. is + /// 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) + public void Render(AnalysisSummary summary, bool byFile, bool noHealth, bool detailed = false, string? sourcePath = null) { ArgumentNullException.ThrowIfNull(summary); diff --git a/src/Sloc.Core/Languages/LanguageRegistry.cs b/src/Sloc.Core/Languages/LanguageRegistry.cs index 9debeb3..95022f9 100644 --- a/src/Sloc.Core/Languages/LanguageRegistry.cs +++ b/src/Sloc.Core/Languages/LanguageRegistry.cs @@ -245,8 +245,7 @@ private static IReadOnlyList CreateLanguages() Extensions = [".sql"], LineCommentTokens = ["--"], BlockComments = [cStyleBlock], - StringLiterals = [singleQuote, doubleQuote], - ShowHealth = false + StringLiterals = [singleQuote, doubleQuote] }, new() { @@ -254,8 +253,7 @@ private static IReadOnlyList CreateLanguages() Extensions = [".ps1", ".psm1", ".psd1"], LineCommentTokens = ["#"], BlockComments = [new BlockComment("<#", "#>")], - StringLiterals = [doubleQuote, singleQuote], - ShowHealth = false + StringLiterals = [doubleQuote, singleQuote] }, new() { diff --git a/tests/Sloc.Cli.Tests/JsonRendererTests.cs b/tests/Sloc.Cli.Tests/JsonRendererTests.cs index 979ac2f..90a3e70 100644 --- a/tests/Sloc.Cli.Tests/JsonRendererTests.cs +++ b/tests/Sloc.Cli.Tests/JsonRendererTests.cs @@ -9,6 +9,22 @@ namespace Sloc.Cli.Tests; /// public class JsonRendererTests { + /// + /// Verifies that the by-file payload emits the per-file array and omits by-language. + /// + [Fact] + public void Render_ByFile_EmitsFilesAndOmitsByLanguage() + { + var summary = BuildSummary(); + + var root = Render(summary, byFile: true, noHealth: false); + + Assert.False(root.TryGetProperty("byLanguage", out _)); + var files = root.GetProperty("files"); + Assert.Equal(1, files.GetArrayLength()); + Assert.Equal("a.cs", files[0].GetProperty("path").GetString()); + } + /// /// Verifies that the by-language payload includes totals, percentages, and health, /// and omits the per-file array. @@ -32,40 +48,6 @@ public void Render_ByLanguage_EmitsTotalsPercentagesAndHealth() Assert.False(root.TryGetProperty("files", out _)); } - /// - /// Verifies that noHealth suppresses only the health field, while percentages - /// remain in the payload. - /// - [Fact] - public void Render_NoHealth_KeepsPercentagesButOmitsHealth() - { - var summary = BuildSummary(); - - var root = Render(summary, byFile: false, noHealth: true); - - Assert.True(root.TryGetProperty("codePct", out _)); - Assert.True(root.TryGetProperty("commentPct", out _)); - var language = root.GetProperty("byLanguage")[0]; - Assert.True(language.TryGetProperty("codePct", out _)); - Assert.False(language.TryGetProperty("health", out _)); - } - - /// - /// Verifies that the by-file payload emits the per-file array and omits by-language. - /// - [Fact] - public void Render_ByFile_EmitsFilesAndOmitsByLanguage() - { - var summary = BuildSummary(); - - var root = Render(summary, byFile: true, noHealth: false); - - Assert.False(root.TryGetProperty("byLanguage", out _)); - var files = root.GetProperty("files"); - Assert.Equal(1, files.GetArrayLength()); - Assert.Equal("a.cs", files[0].GetProperty("path").GetString()); - } - /// /// Verifies that detailed emits both the by-language summary and the per-file /// array in the same document. @@ -100,11 +82,41 @@ public void Render_GeneratedAt_UsesSuppliedTime() Assert.Equal("2026-07-26T10:30:00Z", root.GetProperty("generatedAt").GetString()); } - private static JsonElement Render(AnalysisSummary summary, bool byFile, bool noHealth) + /// + /// Verifies that noHealth suppresses only the health field, while percentages + /// remain in the payload. + /// + [Fact] + public void Render_NoHealth_KeepsPercentagesButOmitsHealth() { - using var writer = new StringWriter(); - new JsonRenderer(writer).Render(summary, byFile, noHealth); - return JsonSerializer.Deserialize(writer.ToString()); + var summary = BuildSummary(); + + var root = Render(summary, byFile: false, noHealth: true); + + Assert.True(root.TryGetProperty("codePct", out _)); + Assert.True(root.TryGetProperty("commentPct", out _)); + var language = root.GetProperty("byLanguage")[0]; + Assert.True(language.TryGetProperty("codePct", out _)); + Assert.False(language.TryGetProperty("health", out _)); + } + + /// + /// Verifies that sourcePath is emitted when supplied and omitted when not. + /// + [Fact] + public void Render_SourcePath_EmittedOnlyWhenSupplied() + { + var summary = BuildSummary(); + + using var withSource = new StringWriter(); + new JsonRenderer(withSource).Render(summary, byFile: false, noHealth: false, sourcePath: "src"); + var rootWithSource = JsonSerializer.Deserialize(withSource.ToString()); + Assert.Equal("src", rootWithSource.GetProperty("sourcePath").GetString()); + + using var withoutSource = new StringWriter(); + new JsonRenderer(withoutSource).Render(summary, byFile: false, noHealth: false); + var rootWithoutSource = JsonSerializer.Deserialize(withoutSource.ToString()); + Assert.False(rootWithoutSource.TryGetProperty("sourcePath", out _)); } private static AnalysisSummary BuildSummary() @@ -119,4 +131,11 @@ private static AnalysisSummary BuildSummary() }; return new AnalysisSummary([file]); } -} + + private static JsonElement Render(AnalysisSummary summary, bool byFile, bool noHealth) + { + using var writer = new StringWriter(); + new JsonRenderer(writer).Render(summary, byFile, noHealth); + return JsonSerializer.Deserialize(writer.ToString()); + } +} \ No newline at end of file diff --git a/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs b/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs index d1e980b..e3447c3 100644 --- a/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs +++ b/tests/Sloc.Core.Tests/GitIgnoreRulesTests.cs @@ -198,8 +198,9 @@ public void Load_GitInfoExclude_IsHonored() } /// - /// Verifies the precedence order documented on : the - /// repo-local .git/info/exclude is lowest precedence among the tiers testable + /// Verifies the precedence order documented on + /// : the + /// repo-local .git/info/exclude is the lowest precedence among the tiers testable /// without touching the real user profile, so a later, more specific .gitignore /// pattern (negation) can override it. The global core.excludesFile tier sits /// below this and is covered separately by