From 7c10a361f06d5fca221470b32ec4400351638029 Mon Sep 17 00:00:00 2001 From: trackd Date: Wed, 24 Sep 2025 10:58:08 +0200 Subject: [PATCH 01/25] feat(streaming): add RenderableBatch & streaming tests; update renderers and TokenProcessor optimizations --- src/Cmdlets/ShowTextMateCmdlet.cs | 52 ++++- .../Markdown/Renderers/CodeBlockRenderer.cs | 4 +- .../Markdown/Renderers/HtmlBlockRenderer.cs | 3 +- .../Markdown/Renderers/ParagraphRenderer.cs | 45 ++-- src/Core/Markdown/Renderers/TableRenderer.cs | 93 ++++---- src/Core/MarkdownRenderer.cs | 8 +- src/Core/RenderableBatch.cs | 49 +++++ src/Core/Rows.cs | 11 + src/Core/TextMateProcessor.cs | 94 +++++++- src/Core/TokenProcessor.cs | 203 ++++++++++++++++-- tests/Core/Markdown/MarkdownRendererTests.cs | 15 +- tests/Core/Markdown/TableRendererTests.cs | 32 +++ tests/Integration/TaskListIntegrationTests.cs | 47 +++- tests/Pester/ShowTextMate.Streaming.Tests.ps1 | 35 +++ 14 files changed, 574 insertions(+), 117 deletions(-) create mode 100644 src/Core/RenderableBatch.cs create mode 100644 src/Core/Rows.cs create mode 100644 tests/Core/Markdown/TableRendererTests.cs create mode 100644 tests/Pester/ShowTextMate.Streaming.Tests.ps1 diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index e1d25c9..612f783 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -3,6 +3,7 @@ using PwshSpectreConsole.TextMate.Extensions; using Spectre.Console; using PwshSpectreConsole.TextMate; +using PwshSpectreConsole.TextMate.Core; namespace PwshSpectreConsole.TextMate.Cmdlets; @@ -12,7 +13,7 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// [Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "String")] [Alias("st","Show-Code")] -[OutputType(typeof(Rows))] +[OutputType(typeof(RenderableBatch))] public sealed class ShowTextMateCmdlet : PSCmdlet { private static readonly string[] NewLineSplit = ["\r\n", "\n", "\r"]; @@ -53,6 +54,14 @@ public sealed class ShowTextMateCmdlet : PSCmdlet [Parameter] public SwitchParameter PassThru { get; set; } + [Parameter( + ParameterSetName = "Path" + )] + public SwitchParameter Stream { get; set; } + + [Parameter(ParameterSetName = "Path")] + public int BatchSize { get; set; } = 1000; + protected override void ProcessRecord() { if (ParameterSetName == "String" && InputObject is not null) @@ -85,7 +94,7 @@ protected override void ProcessRecord() { try { - Rows? result = ProcessPathInput(); + Spectre.Console.Rows? result = ProcessPathInput(); if (result is not null) { WriteObject(result); @@ -113,7 +122,7 @@ protected override void EndProcessing() try { - Rows? result = ProcessStringInput(); + Spectre.Console.Rows? result = ProcessStringInput(); if (result is not null) { WriteObject(result); @@ -129,7 +138,7 @@ protected override void EndProcessing() } } - private Rows? ProcessStringInput() + private Spectre.Console.Rows? ProcessStringInput() { if (_inputObjectBuffer.Count == 0) { @@ -166,7 +175,7 @@ protected override void EndProcessing() return Converter.ProcessLines(strings, Theme, "powershell", isExtension: false); } - private Rows? ProcessPathInput() + private Spectre.Console.Rows? ProcessPathInput() { FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(Path)); @@ -178,6 +187,33 @@ protected override void EndProcessing() // Decide how to interpret based on precedence: // 1) Language token (can be a language id OR an extension) // 2) File extension + if (Stream.IsPresent) + { + // Stream file in batches + int batchIndex = 0; + if (!string.IsNullOrWhiteSpace(Language)) + { + (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language!); + WriteVerbose($"Streaming file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")}) in batches of {BatchSize}"); + foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension)) + { + // Attach a stable batch index so consumers can track ordering + var indexed = new RenderableBatch(batch.Renderables, batchIndex: batchIndex++, fileOffset: batch.FileOffset); + WriteObject(indexed); + } + return null; + } + + string extension = filePath.Extension; + WriteVerbose($"Streaming file: {filePath.FullName} using file extension: {extension} in batches of {BatchSize}"); + foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, extension, true)) + { + var indexed = new RenderableBatch(batch.Renderables, batchIndex: batchIndex++, fileOffset: batch.FileOffset); + WriteObject(indexed); + } + return null; + } + string[] lines = File.ReadAllLines(filePath.FullName); if (!string.IsNullOrWhiteSpace(Language)) { @@ -185,8 +221,8 @@ protected override void EndProcessing() WriteVerbose($"Processing file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")})"); return Converter.ProcessLines(lines, Theme, token, isExtension: asExtension); } - string extension = filePath.Extension; - WriteVerbose($"Processing file: {filePath.FullName} using file extension: {extension}"); - return Converter.ProcessLines(lines, Theme, extension, isExtension: true); + string extension2 = filePath.Extension; + WriteVerbose($"Processing file: {filePath.FullName} using file extension: {extension2}"); + return Converter.ProcessLines(lines, Theme, extension2, isExtension: true); } } diff --git a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs index d3e190c..4b1410a 100644 --- a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs +++ b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs @@ -38,7 +38,9 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them Rows? rows = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); if (rows is not null) { - return new Panel(rows) + // Convert internal Rows into Spectre.Console.Rows for Panel consumption + var spectreRows = new Spectre.Console.Rows(rows.Renderables); + return new Panel(spectreRows) .Border(BoxBorder.Rounded) .Header(language, Justify.Left); } diff --git a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs index 693a5e7..b8b7ae1 100644 --- a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs +++ b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs @@ -28,7 +28,8 @@ public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName the Rows? htmlRows = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); if (htmlRows is not null) { - return new Panel(htmlRows) + var spectreRows = new Spectre.Console.Rows(htmlRows.Renderables); + return new Panel(spectreRows) .Border(BoxBorder.Rounded) .Header("html", Justify.Left); } diff --git a/src/Core/Markdown/Renderers/ParagraphRenderer.cs b/src/Core/Markdown/Renderers/ParagraphRenderer.cs index 4e1a3c2..184cde8 100644 --- a/src/Core/Markdown/Renderers/ParagraphRenderer.cs +++ b/src/Core/Markdown/Renderers/ParagraphRenderer.cs @@ -2,9 +2,12 @@ using Markdig.Extensions.AutoLinks; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using Markdig.Extensions; +using Markdig.Extensions.TaskLists; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using System.Text; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -12,8 +15,11 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// Paragraph renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and avoids double-parsing overhead. /// -internal static class ParagraphRenderer +internal static partial class ParagraphRenderer { + // reuse static arrays for common scope queries to avoid allocating new arrays per call + private static readonly string[] LinkScope = ["markup.underline.link"]; + /// /// Renders a paragraph block by building Spectre.Console Paragraph objects directly. /// This approach eliminates VT escaping issues and improves performance. @@ -88,7 +94,7 @@ internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline ProcessAutoLinkInline(paragraph, autoLink, theme); break; - case Markdig.Extensions.TaskLists.TaskList taskList: + case TaskList taskList: // TaskList items are handled at the list level, skip here break; @@ -217,24 +223,14 @@ private static void ProcessLinkInlineWithDecoration(Paragraph paragraph, LinkInl linkText = link.Url ?? ""; } - // Get theme colors for links - string[] linkScopes = new[] { "markup.underline.link" }; - (int linkFg, int linkBg, FontStyle linkFs) = TokenProcessor.ExtractThemeProperties( - new MarkdownToken(linkScopes), theme); - - // Create link styling with emphasis decoration combined - Color? foregroundColor = linkFg != -1 ? StyleHelper.GetColor(linkFg, theme) : Color.Blue; - Color? backgroundColor = linkBg != -1 ? StyleHelper.GetColor(linkBg, theme) : null; - Decoration linkDecoration = StyleHelper.GetDecoration(linkFs) | Decoration.Underline | emphasisDecoration; - - // Create style with link parameter for clickable links - var linkStyle = new Style( - foreground: foregroundColor, - background: backgroundColor, - decoration: linkDecoration, - link: link.Url // This makes it clickable! - ); + // Use cached style if available + Style? linkStyleBase = TokenProcessor.GetStyleForScopes(LinkScope, theme); + Color? foregroundColor = linkStyleBase?.Foreground ?? Color.Blue; + Color? backgroundColor = linkStyleBase?.Background ?? null; + Decoration baseDecoration = linkStyleBase is not null ? linkStyleBase.Decoration : Decoration.None; + Decoration linkDecoration = baseDecoration | Decoration.Underline | emphasisDecoration; + var linkStyle = new Style(foregroundColor, backgroundColor, linkDecoration, link.Url); paragraph.Append(linkText, linkStyle); } @@ -244,7 +240,7 @@ private static void ProcessLinkInlineWithDecoration(Paragraph paragraph, LinkInl private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Theme theme) { // Get theme colors for inline code - string[] codeScopes = new[] { "markup.inline.raw" }; + string[] codeScopes = ["markup.inline.raw"]; (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( new MarkdownToken(codeScopes), theme); @@ -303,7 +299,7 @@ private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline au } // Get theme colors for links - string[] linkScopes = new[] { "markup.underline.link" }; + string[] linkScopes = ["markup.underline.link"]; (int linkFg, int linkBg, FontStyle linkFs) = TokenProcessor.ExtractThemeProperties( new MarkdownToken(linkScopes), theme); @@ -345,7 +341,7 @@ private static bool TryParseUsernameLinks(string text, out TextSegment[] segment var segmentList = new List(); // Simple regex to find @username patterns - var usernamePattern = new System.Text.RegularExpressions.Regex(@"@[a-zA-Z0-9_-]+"); + var usernamePattern = RegNumLet(); MatchCollection matches = usernamePattern.Matches(text); if (matches.Count == 0) @@ -378,7 +374,7 @@ private static bool TryParseUsernameLinks(string text, out TextSegment[] segment return true; } - private static void ExtractInlineTextRecursive(Inline inline, System.Text.StringBuilder builder) + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { switch (inline) { @@ -405,4 +401,7 @@ private static void ExtractInlineTextRecursive(Inline inline, System.Text.String break; } } + + [GeneratedRegex(@"@[a-zA-Z0-9_-]+")] + private static partial Regex RegNumLet(); } diff --git a/src/Core/Markdown/Renderers/TableRenderer.cs b/src/Core/Markdown/Renderers/TableRenderer.cs index 826770f..4dfdf60 100644 --- a/src/Core/Markdown/Renderers/TableRenderer.cs +++ b/src/Core/Markdown/Renderers/TableRenderer.cs @@ -1,9 +1,12 @@ using Markdig.Extensions.Tables; +using Markdig.Helpers; using Markdig.Syntax; using Markdig.Syntax.Inlines; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using System.Text; +using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -60,12 +63,12 @@ internal static class TableRenderer else { // No explicit headers, use first row as headers - (bool isHeader, List cells) firstRow = allRows.FirstOrDefault(); - if (firstRow.cells?.Count > 0) + (bool isHeader, List cells) = allRows.FirstOrDefault(); + if (cells?.Count > 0) { - for (int i = 0; i < firstRow.cells.Count; i++) + for (int i = 0; i < cells.Count; i++) { - TableCellContent cell = firstRow.cells[i]; + TableCellContent cell = cells[i]; var column = new TableColumn(cell.Text); if (i < table.ColumnDefinitions.Count) { @@ -79,7 +82,7 @@ internal static class TableRenderer } spectreTable.AddColumn(column); } - allRows = allRows.Skip(1).ToList(); + allRows = [.. allRows.Skip(1)]; } } @@ -104,12 +107,12 @@ internal static class TableRenderer /// /// Represents the content and styling of a table cell. /// - private sealed record TableCellContent(string Text, TableColumnAlign? Alignment); + internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment); /// /// Extracts table data with optimized cell content processing. /// - private static List<(bool isHeader, List cells)> ExtractTableDataOptimized( + internal static List<(bool isHeader, List cells)> ExtractTableDataOptimized( Markdig.Extensions.Tables.Table table, Theme theme) { var result = new List<(bool isHeader, List cells)>(); @@ -140,7 +143,7 @@ private sealed record TableCellContent(string Text, TableColumnAlign? Alignment) /// private static string ExtractCellTextOptimized(TableCell cell, Theme theme) { - var textBuilder = new System.Text.StringBuilder(); + var textBuilder = StringBuilderPool.Rent(); foreach (Block block in cell) { @@ -154,32 +157,39 @@ private static string ExtractCellTextOptimized(TableCell cell, Theme theme) } } - return textBuilder.ToString().Trim(); + string result = textBuilder.ToString().Trim(); + TextMate.Helpers.StringBuilderPool.Return(textBuilder); + return result; } /// /// Extracts text from inline elements optimized for table cells. /// - private static void ExtractInlineTextOptimized(ContainerInline inlines, System.Text.StringBuilder builder) + private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBuilder builder) { + // Small optimization: use a borrowed buffer for frequently accessed literal content instead of repeated ToString allocations. foreach (Inline inline in inlines) { switch (inline) { case LiteralInline literal: - builder.Append(literal.Content.ToString()); + // Append span directly from the underlying string to avoid creating intermediate allocations + StringSlice slice = literal.Content; + if (slice.Text is not null && slice.Length > 0) + { + builder.Append(slice.Text.AsSpan(slice.Start, slice.Length)); + } break; case EmphasisInline emphasis: - // For tables, we extract just the text content ExtractInlineTextRecursive(emphasis, builder); break; - case Markdig.Syntax.Inlines.CodeInline code: + case CodeInline code: builder.Append(code.Content); break; - case Markdig.Syntax.Inlines.LinkInline link: + case LinkInline link: ExtractInlineTextRecursive(link, builder); break; @@ -193,12 +203,15 @@ private static void ExtractInlineTextOptimized(ContainerInline inlines, System.T /// /// Recursively extracts text from inline elements. /// - private static void ExtractInlineTextRecursive(Inline inline, System.Text.StringBuilder builder) + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { switch (inline) { case LiteralInline literal: - builder.Append(literal.Content.ToString()); + if (literal.Content.Text is not null && literal.Content.Length > 0) + { + builder.Append(literal.Content.Text.AsSpan(literal.Content.Start, literal.Content.Length)); + } break; case ContainerInline container: @@ -208,8 +221,8 @@ private static void ExtractInlineTextRecursive(Inline inline, System.Text.String } break; - case Markdig.Syntax.Inlines.LeafInline leaf: - if (leaf is Markdig.Syntax.Inlines.CodeInline code) + case LeafInline leaf: + if (leaf is CodeInline code) { builder.Append(code.Content); } @@ -222,16 +235,12 @@ private static void ExtractInlineTextRecursive(Inline inline, System.Text.String /// private static Style GetTableBorderStyle(Theme theme) { - // Get theme colors for table borders - string[] borderScopes = new[] { "punctuation.definition.table" }; - (int borderFg, int borderBg, FontStyle borderFs) = TokenProcessor.ExtractThemeProperties( - new MarkdownToken(borderScopes), theme); - - if (borderFg != -1) + string[] borderScopes = ["punctuation.definition.table"]; + Style? style = TokenProcessor.GetStyleForScopes(borderScopes, theme); + if (style is not null) { - return new Style(foreground: StyleHelper.GetColor(borderFg, theme)); + return style; } - return new Style(foreground: Color.Grey); } @@ -240,16 +249,12 @@ private static Style GetTableBorderStyle(Theme theme) /// private static Style GetHeaderStyle(Theme theme) { - // Get theme colors for table headers - string[] headerScopes = new[] { "markup.heading.table" }; - (int headerFg, int headerBg, FontStyle headerFs) = TokenProcessor.ExtractThemeProperties( - new MarkdownToken(headerScopes), theme); - - Color? foregroundColor = headerFg != -1 ? StyleHelper.GetColor(headerFg, theme) : Color.Yellow; - Color? backgroundColor = headerBg != -1 ? StyleHelper.GetColor(headerBg, theme) : null; - Decoration decoration = StyleHelper.GetDecoration(headerFs) | Decoration.Bold; - - return new Style(foregroundColor, backgroundColor, decoration); + string[] headerScopes = ["markup.heading.table"]; + Style? baseStyle = TokenProcessor.GetStyleForScopes(headerScopes, theme); + Color fgColor = baseStyle?.Foreground ?? Color.Yellow; + Color? bgColor = baseStyle?.Background; + Decoration decoration = (baseStyle is not null ? baseStyle.Decoration : Decoration.None) | Decoration.Bold; + return new Style(fgColor, bgColor, decoration); } /// @@ -257,15 +262,11 @@ private static Style GetHeaderStyle(Theme theme) /// private static Style GetCellStyle(Theme theme) { - // Get theme colors for table cells - string[] cellScopes = new[] { "markup.table.cell" }; - (int cellFg, int cellBg, FontStyle cellFs) = TokenProcessor.ExtractThemeProperties( - new MarkdownToken(cellScopes), theme); - - Color? foregroundColor = cellFg != -1 ? StyleHelper.GetColor(cellFg, theme) : Color.White; - Color? backgroundColor = cellBg != -1 ? StyleHelper.GetColor(cellBg, theme) : null; - Decoration decoration = StyleHelper.GetDecoration(cellFs); - - return new Style(foregroundColor, backgroundColor, decoration); + string[] cellScopes = ["markup.table.cell"]; + Style? baseStyle = TokenProcessor.GetStyleForScopes(cellScopes, theme); + Color fgColor = baseStyle?.Foreground ?? Color.White; + Color? bgColor = baseStyle?.Background; + Decoration decoration = baseStyle?.Decoration ?? Decoration.None; + return new Style(fgColor, bgColor, decoration); } } diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index be7655b..6b4c137 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -122,10 +122,12 @@ private static void ProcessMarkdownTokens(IToken[] tokens, string line, Theme th if (startIndex >= endIndex) continue; ReadOnlySpan textSpan = line.SubstringAsSpan(startIndex, endIndex); - (int foreground, int background, FontStyle fontStyle) = TokenProcessor.ExtractThemeProperties(token, theme); - (string escapedText, Style? style) = TokenProcessor.WriteTokenOptimized(textSpan, foreground, background, fontStyle, theme); - builder.AppendWithStyle(style, escapedText); + // Use the cached Style where possible to avoid rebuilding Style objects per token + Style? style = TokenProcessor.GetStyleForScopes(token.Scopes, theme); + + // Append escaped/unstyled text directly to the provided StringBuilder + TokenProcessor.WriteTokenOptimized(builder, textSpan, style, theme, escapeMarkup: true); } } } diff --git a/src/Core/RenderableBatch.cs b/src/Core/RenderableBatch.cs new file mode 100644 index 0000000..a0885eb --- /dev/null +++ b/src/Core/RenderableBatch.cs @@ -0,0 +1,49 @@ +using Spectre.Console.Rendering; +using Spectre.Console; + +namespace PwshSpectreConsole.TextMate.Core; + +public sealed class RenderableBatch : IRenderable +{ + public IRenderable[] Renderables { get; } + + /// + /// Zero-based batch index for ordering when streaming. + /// + public int BatchIndex { get; } + + /// + /// Zero-based file offset (starting line number) for this batch. + /// + public long FileOffset { get; } + + /// + /// Number of rendered lines (rows) in this batch. + /// + public int LineCount => Renderables?.Length ?? 0; + + public RenderableBatch(IRenderable[] renderables, int batchIndex = 0, long fileOffset = 0) + { + Renderables = renderables ?? []; + BatchIndex = batchIndex; + FileOffset = fileOffset; + } + + public IEnumerable Render(RenderOptions options, int maxWidth) + { + foreach (IRenderable r in Renderables) + { + foreach (Segment s in r.Render(options, maxWidth)) + yield return s; + } + } + + public Measurement Measure(RenderOptions options, int maxWidth) + { + // Return a conservative, permissive measurement: min = 0, max = maxWidth. + // This avoids depending on concrete Measurement properties across Spectre.Console versions. + return new Measurement(0, maxWidth); + } + + public Spectre.Console.Rows ToSpectreRows() => new(Renderables); +} diff --git a/src/Core/Rows.cs b/src/Core/Rows.cs new file mode 100644 index 0000000..b359078 --- /dev/null +++ b/src/Core/Rows.cs @@ -0,0 +1,11 @@ +using Spectre.Console.Rendering; + +namespace PwshSpectreConsole.TextMate.Core; + +/// +/// Container for rendered rows returned by renderers. +/// +public sealed record Rows(IRenderable[] Renderables) +{ + public static Rows Empty { get; } = new Rows(Array.Empty()); +} diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 546d7c1..6013f60 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -50,14 +50,21 @@ public static class TextMateProcessor try { (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); - IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); - + var options = new TextMateSharp.Registry.RegistryOptions(themeName); + string initialScope = isExtension ? options.GetScopeByExtension(grammarId) : options.GetScopeByLanguageId(grammarId); + IGrammar? grammar = null; + try + { + grammar = registry.LoadGrammar(initialScope); + } + catch (TextMateSharp.TMException ex) + { + // Re-throw with a clearer message for our callers/tests + throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}", ex); + } if (grammar is null) { - string errorMessage = isExtension - ? $"Grammar not found for file extension: {grammarId}" - : $"Grammar not found for language: {grammarId}"; - throw new InvalidOperationException(errorMessage); + throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); } // Use optimized rendering based on grammar type @@ -138,10 +145,83 @@ private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar gramma ruleStack = result.RuleStack; TokenProcessor.ProcessTokensBatchNoEscape(result.Tokens, line, theme, builder, null, lineIndex); string lineMarkup = builder.ToString(); - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); + // Use Text (raw content) for code blocks so markup characters are preserved + // and not interpreted by the Markup parser. + rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Text(lineMarkup)); builder.Clear(); } return new Rows(rows.ToArray()); } + + /// + /// Processes an enumerable of lines in batches to support streaming/low-memory processing. + /// Yields a Rows result for each processed batch. + /// + public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + { + ArgumentNullException.ThrowIfNull(lines, nameof(lines)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize, nameof(batchSize)); + + var buffer = new List(batchSize); + int batchIndex = 0; + long fileOffset = 0; // starting line index for the next batch + + // Load theme and registry once and then resolve the requested grammar scope + // directly on the registry. Avoid using the global grammar cache here because + // TextMateSharp's Registry manages its own internal grammar store and repeated + // LoadGrammar calls or cross-registry caching can cause duplicate-key exceptions. + (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); + var options = new TextMateSharp.Registry.RegistryOptions(themeName); + string initialScope = isExtension ? options.GetScopeByExtension(grammarId) : options.GetScopeByLanguageId(grammarId); + IGrammar? grammar = null; + try + { + grammar = registry.LoadGrammar(initialScope); + } + catch (TextMateSharp.TMException ex) + { + // Re-throw with a clearer message for our callers/tests + throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}", ex); + } + if (grammar is null) + { + throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); + } + + bool useMarkdownRenderer = grammar.GetName() == "Markdown"; + + foreach (string? line in lines) + { + buffer.Add(line ?? string.Empty); + if (buffer.Count >= batchSize) + { + // Render the batch using the already-loaded grammar and theme + Rows? result = useMarkdownRenderer + ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) + : StandardRenderer.Render([.. buffer], theme, grammar, null); + if (result is not null) + yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex++, fileOffset: fileOffset); + buffer.Clear(); + fileOffset += batchSize; + } + } + if (buffer.Count > 0) + { + Rows? result = useMarkdownRenderer + ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) + : StandardRenderer.Render([.. buffer], theme, grammar, null); + if (result is not null) + yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex++, fileOffset: fileOffset); + } + } + + /// + /// Helper to stream a file by reading lines lazily and processing them in batches. + /// + public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + { + if (!File.Exists(filePath)) throw new FileNotFoundException(filePath); + return ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension); + } } diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index bf9ad1c..4973930 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -3,6 +3,8 @@ using TextMateSharp.Grammars; using TextMateSharp.Themes; using PwshSpectreConsole.TextMate.Extensions; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; namespace PwshSpectreConsole.TextMate.Core; @@ -12,8 +14,17 @@ namespace PwshSpectreConsole.TextMate.Core; /// internal static class TokenProcessor { + // Toggle caching via environment variable PSTEXTMATE_DISABLE_STYLECACHE=1 to disable + public static readonly bool EnableStyleCache = Environment.GetEnvironmentVariable("PSTEXTMATE_DISABLE_STYLECACHE") != "1"; + // Cache theme extraction results per (scopesKey, themeInstanceHash) + private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), (int fg, int bg, FontStyle fs)> _themePropertyCache = new(); + // Cache Style results per (scopesKey, themeInstanceHash) + private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> _styleCache = new(); + /// /// Processes tokens in batches for better cache locality and performance. + /// This version uses the style cache to avoid re-creating Style objects per token + /// and appends text directly into the provided StringBuilder to avoid temporary strings. /// /// Tokens to process /// Source line text @@ -35,10 +46,20 @@ public static void ProcessTokensBatch( if (startIndex >= endIndex) continue; ReadOnlySpan textSpan = line.SubstringAsSpan(startIndex, endIndex); - (int foreground, int background, FontStyle fontStyle) = ExtractThemeProperties(token, theme); - (string escapedText, Style? style) = WriteTokenOptimized(textSpan, foreground, background, fontStyle, theme); - builder.AppendWithStyle(style, escapedText); + // Use cached Style where possible to avoid rebuilding Style objects per token + Style? style = GetStyleForScopes(token.Scopes, theme); + + // Only extract numeric theme properties when debugging is enabled to reduce work + (int foreground, int background, FontStyle fontStyle) = ( -1, -1, FontStyle.NotSet ); + if (debugCallback is not null) + { + (foreground, background, fontStyle) = ExtractThemeProperties(token, theme); + } + + // Use the returning API so callers can append with style consistently (prevents markup regressions) + (string processedText, Style? resolvedStyle) = WriteTokenOptimizedReturn(textSpan, style, theme, escapeMarkup: true); + builder.AppendWithStyle(resolvedStyle, processedText); debugCallback?.Invoke(new TokenDebugInfo { @@ -82,10 +103,17 @@ public static void ProcessTokensBatchNoEscape( if (startIndex >= endIndex) continue; ReadOnlySpan textSpan = line.SubstringAsSpan(startIndex, endIndex); - (int foreground, int background, FontStyle fontStyle) = ExtractThemeProperties(token, theme); - (string processedText, Style? style) = WriteTokenOptimized(textSpan, foreground, background, fontStyle, theme, escapeMarkup: false); - builder.AppendWithStyle(style, processedText); + Style? style = GetStyleForScopes(token.Scopes, theme); + + (int foreground, int background, FontStyle fontStyle) = ( -1, -1, FontStyle.NotSet ); + if (debugCallback is not null) + { + (foreground, background, fontStyle) = ExtractThemeProperties(token, theme); + } + + // Use the span-based writer to append raw text without additional escaping + WriteTokenOptimized(builder, textSpan, style, theme, escapeMarkup: false); debugCallback?.Invoke(new TokenDebugInfo { @@ -105,6 +133,16 @@ public static void ProcessTokensBatchNoEscape( public static (int foreground, int background, FontStyle fontStyle) ExtractThemeProperties(IToken token, Theme theme) { + // Build a compact key from token scopes (they're mostly immutable per token) + string scopesKey = string.Join('\u001F', token.Scopes); + int themeHash = RuntimeHelpers.GetHashCode(theme); + var cacheKey = (scopesKey, themeHash); + + if (_themePropertyCache.TryGetValue(cacheKey, out (int fg, int bg, FontStyle fs) cached)) + { + return (cached.fg, cached.bg, cached.fs); + } + int foreground = -1; int background = -1; FontStyle fontStyle = FontStyle.NotSet; @@ -119,30 +157,161 @@ public static (int foreground, int background, FontStyle fontStyle) ExtractTheme fontStyle = themeRule.fontStyle; } - return (foreground, background, fontStyle); + // Store in cache even if defaults (-1) for future lookups + var result = (foreground, background, fontStyle); + _themePropertyCache.TryAdd(cacheKey, result); + return result; } - public static (string escapedText, Style? style) WriteTokenOptimized( + + /// + /// Returns processed text and Style for the provided token span. This is the non-allocating + /// replacement for the original API callers previously relied on. It preserves the behavior + /// where the caller appends via AppendWithStyle so that Markup escaping and concatenation + /// semantics remain identical. + /// + public static (string processedText, Style? style) WriteTokenOptimizedReturn( ReadOnlySpan text, - int foreground, - int background, - FontStyle fontStyle, + Style? styleHint, Theme theme, bool escapeMarkup = true) { string processedText = escapeMarkup ? Markup.Escape(text.ToString()) : text.ToString(); // Early return for no styling needed - if (foreground == -1 && background == -1 && fontStyle == FontStyle.NotSet) + if (styleHint is null) + { + return (processedText, null); + } + + // If the style serializes to an empty markup string, treat it as no style + // to avoid emitting empty [] tags which Spectre.Markup rejects. + string styleMarkup = styleHint.ToMarkup(); + if (string.IsNullOrEmpty(styleMarkup)) { return (processedText, null); } - Decoration decoration = StyleHelper.GetDecoration(fontStyle); - Color backgroundColor = StyleHelper.GetColor(background, theme); - Color foregroundColor = StyleHelper.GetColor(foreground, theme); - Style style = new(foregroundColor, backgroundColor, decoration); + // Otherwise return the style as resolved + return (processedText, styleHint); + } + + /// + /// Append the provided text span into the builder with optional style and optional markup escaping. + /// (Existing fast-path writer retained for specialized callers.) + /// + public static void WriteTokenOptimized( + StringBuilder builder, + ReadOnlySpan text, + Style? style, + Theme theme, + bool escapeMarkup = true) + { + // Fast-path: if no escaping needed, append span directly with style-aware overload + if (!escapeMarkup) + { + if (style is not null) + { + string styleMarkup = style.ToMarkup(); + if (!string.IsNullOrEmpty(styleMarkup)) + { + builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); + } + else + { + builder.Append(text).AppendLine(); + } + } + else + { + builder.Append(text).AppendLine(); + } + return; + } + + // Check for presence of characters that require escaping. Most common tokens do not contain '[' or ']' + bool needsEscape = false; + foreach (char c in text) + { + if (c == '[' || c == ']') + { + needsEscape = true; + break; + } + } + + if (!needsEscape) + { + // Safe fast-path: append span directly + if (style is not null) + { + string styleMarkup = style.ToMarkup(); + if (!string.IsNullOrEmpty(styleMarkup)) + { + builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); + } + else + { + builder.Append(text).AppendLine(); + } + } + else + { + builder.Append(text).AppendLine(); + } + return; + } + + // Slow path: fallback to the reliable Markup.Escape for correctness when special characters are present + string escaped = Markup.Escape(text.ToString()); + if (style is not null) + { + string styleMarkup = style.ToMarkup(); + if (!string.IsNullOrEmpty(styleMarkup)) + { + builder.Append('[').Append(styleMarkup).Append(']').Append(escaped).Append("[/]").AppendLine(); + } + else + { + builder.Append(escaped).AppendLine(); + } + } + else + { + builder.Append(escaped).AppendLine(); + } + } + + /// + /// Returns a cached Style for the given scopes and theme. Returns null for default/no-style. + /// + public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) + { + string scopesKey = string.Join('\u001F', scopes); + int themeHash = RuntimeHelpers.GetHashCode(theme); + var cacheKey = (scopesKey, themeHash); + + if (_styleCache.TryGetValue(cacheKey, out Style? cached)) + { + return cached; + } + + // Fallback to extracting properties and building a Style + // Create a dummy token-like enumerable for existing ExtractThemeProperties method + var token = new MarkdownToken([.. scopes]); + var (fg, bg, fs) = ExtractThemeProperties(token, theme); + if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) + { + _styleCache.TryAdd(cacheKey, null); + return null; + } + + Color? foregroundColor = fg != -1 ? StyleHelper.GetColor(fg, theme) : null; + Color? backgroundColor = bg != -1 ? StyleHelper.GetColor(bg, theme) : null; + Decoration decoration = StyleHelper.GetDecoration(fs); - return (processedText, style); + var style = new Style(foregroundColor, backgroundColor, decoration); + _styleCache.TryAdd(cacheKey, style); + return style; } } diff --git a/tests/Core/Markdown/MarkdownRendererTests.cs b/tests/Core/Markdown/MarkdownRendererTests.cs index 8ada35c..9810f9f 100644 --- a/tests/Core/Markdown/MarkdownRendererTests.cs +++ b/tests/Core/Markdown/MarkdownRendererTests.cs @@ -14,7 +14,7 @@ public void Render_SimpleMarkdown_ReturnsValidRows() var themeName = ThemeName.DarkPlus; // Act - var result = MarkdownRenderer.Render(markdown, theme, themeName); + var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdown, theme, themeName); // Assert result.Should().NotBeNull(); @@ -30,7 +30,7 @@ public void Render_EmptyMarkdown_ReturnsEmptyRows() var themeName = ThemeName.DarkPlus; // Act - var result = MarkdownRenderer.Render(markdown, theme, themeName); + var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdown, theme, themeName); // Assert result.Should().NotBeNull(); @@ -46,7 +46,7 @@ public void Render_CodeBlock_ProducesCodeBlockRenderer() var themeName = ThemeName.DarkPlus; // Act - var result = MarkdownRenderer.Render(markdown, theme, themeName); + var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdown, theme, themeName); // Assert result.Should().NotBeNull(); @@ -65,7 +65,7 @@ public void Render_Headings_HandlesAllLevels(string markdownHeading) var themeName = ThemeName.DarkPlus; // Act - var result = MarkdownRenderer.Render(markdownHeading, theme, themeName); + var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdownHeading, theme, themeName); // Assert result.Should().NotBeNull(); @@ -74,9 +74,8 @@ public void Render_Headings_HandlesAllLevels(string markdownHeading) private static Theme CreateTestTheme() { - // Create a minimal theme for testing - var registryOptions = new RegistryOptions(ThemeName.DarkPlus); - var registry = new Registry(registryOptions); - return registry.GetTheme(); + // Use the internal CacheManager to get a cached Theme instance for tests + var (registry, theme) = PwshSpectreConsole.TextMate.Infrastructure.CacheManager.GetCachedTheme(ThemeName.DarkPlus); + return theme; } } diff --git a/tests/Core/Markdown/TableRendererTests.cs b/tests/Core/Markdown/TableRendererTests.cs new file mode 100644 index 0000000..3646dfb --- /dev/null +++ b/tests/Core/Markdown/TableRendererTests.cs @@ -0,0 +1,32 @@ +using System.Linq; +using Markdig; +using Markdig.Syntax; +using Markdig.Extensions.Tables; +using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; + +public class TableRendererTests +{ + [Fact] + public void ExtractTableDataOptimized_DoesNotDuplicateHeaders() + { + // Arrange: simple markdown table + var markdown = "| Name | Value |\n| ---- | ----- |\n| Alpha | 1 |\n| Beta | 2 |"; + var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + var document = Markdig.Markdown.Parse(markdown, pipeline); + var table = document.OfType().FirstOrDefault(); + var (_, theme) = PwshSpectreConsole.TextMate.Infrastructure.CacheManager.GetCachedTheme(ThemeName.DarkPlus); + + // Act + var rows = TableRenderer.ExtractTableDataOptimized(table ?? throw new InvalidOperationException("table not found"), theme); + + // Assert + // First row should be header, and should not appear twice in data rows + rows.Should().NotBeNull(); + rows.Should().HaveCount(3); // header + 2 data rows + rows[0].isHeader.Should().BeTrue(); + rows.Skip(1).All(r => !r.isHeader).Should().BeTrue(); + } +} diff --git a/tests/Integration/TaskListIntegrationTests.cs b/tests/Integration/TaskListIntegrationTests.cs index 1fd1866..88c726f 100644 --- a/tests/Integration/TaskListIntegrationTests.cs +++ b/tests/Integration/TaskListIntegrationTests.cs @@ -1,4 +1,5 @@ using PwshSpectreConsole.TextMate.Core; +using System.Threading; using TextMateSharp.Grammars; namespace PwshSpectreConsole.TextMate.Tests.Integration; @@ -94,10 +95,50 @@ public void MarkdigSpectreMarkdownRenderer_ComplexTaskList_RendersWithoutReflect result.Renderables.Should().HaveCountGreaterThan(3); } + [Fact] + public void StreamingProcessFileInBatches_ProducesMultipleBatchesWithOffsets() + { + // Arrange - create a temporary file with multiple lines that cross batch boundaries + string[] lines = Enumerable.Range(0, 2500).Select(i => i % 5 == 0 ? "// comment line" : "var x = 1; // code").ToArray(); + string temp = Path.GetTempFileName(); + File.WriteAllLines(temp, lines); + + try + { + // Act + var batches = TextMate.Core.TextMateProcessor.ProcessFileInBatches(temp, 1000, ThemeName.DarkPlus, ".cs", isExtension: true).ToList(); + + // Assert + batches.Should().NotBeEmpty(); + batches.Count.Should().BeGreaterThan(1); + // Offsets should increase and cover the whole file + long covered = batches.Sum(b => b.LineCount); + covered.Should().BeGreaterOrEqualTo(lines.Length); + // Batch indexes should be unique and sequential + batches.Select(b => b.BatchIndex).Should().BeInAscendingOrder(); + } + finally + { + // Retry deletion a few times to avoid transient sharing violations on Windows + const int maxAttempts = 5; + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + try + { + if (File.Exists(temp)) File.Delete(temp); + break; + } + catch (IOException) + { + Thread.Sleep(100); + } + } + } + } + private static TextMateSharp.Themes.Theme CreateTestTheme() { - var registryOptions = new TextMateSharp.Registry.RegistryOptions(ThemeName.DarkPlus); - var registry = new TextMateSharp.Registry.Registry(registryOptions); - return registry.GetTheme(); + var (_, theme) = TextMate.Infrastructure.CacheManager.GetCachedTheme(ThemeName.DarkPlus); + return theme; } } diff --git a/tests/Pester/ShowTextMate.Streaming.Tests.ps1 b/tests/Pester/ShowTextMate.Streaming.Tests.ps1 new file mode 100644 index 0000000..72606d5 --- /dev/null +++ b/tests/Pester/ShowTextMate.Streaming.Tests.ps1 @@ -0,0 +1,35 @@ +Describe 'Show-TextMate -Stream' { + It 'emits multiple RenderableBatch objects with correct coverage' { + # Arrange + $lines = 0..2499 | ForEach-Object { if ($_ % 5 -eq 0) { '// comment line' } else { 'var x = 1; // code' } } + $temp = [System.IO.Path]::GetTempFileName() + Set-Content -Path $temp -Value $lines -NoNewline + + try { + # The build pipeline (build.ps1) should have published the module into the Output folder + $moduleManifest = Join-Path -Path (Resolve-Path "$PSScriptRoot\..\..\Output") -ChildPath 'PSTextMate.psd1' + if (-Not (Test-Path $moduleManifest)) { + throw "Module manifest not found at $moduleManifest. Ensure build.ps1 was run prior to tests." + } + + Import-Module -Name $moduleManifest -Force -ErrorAction Stop + + # Act + $batches = Show-TextMate -Path $temp -Stream -BatchSize 1000 | Where-Object { $_ -is [PwshSpectreConsole.TextMate.Core.RenderableBatch] } | Select-Object -Property BatchIndex, LineCount + + # Assert + $batches | Should -Not -BeNullOrEmpty + $batches.Count | Should -BeGreaterThan 1 + + $covered = ($batches | Measure-Object -Property LineCount -Sum).Sum + $covered | Should -BeGreaterOrEqualTo $lines.Count + + # Ensure batch indexes are sequential starting from zero + $expected = 0..($batches.Count - 1) + ($batches | ForEach-Object { $_.BatchIndex }) | Should -Be $expected + } + finally { + Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue + } + } +} From 40cc866c5182fdbb73f45663d7aacb75e7f7c63f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 06:19:23 +0000 Subject: [PATCH 02/25] Bump Roslynator.CodeFixes from 4.14.0 to 4.14.1 --- updated-dependencies: - dependency-name: Roslynator.CodeFixes dependency-version: 4.14.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- src/PSTextMate.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index c880cde..2c04f8a 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -23,7 +23,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 781768c2ac8a613aed3e03e7e7bc1f484b5c5a42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 06:19:24 +0000 Subject: [PATCH 03/25] Bump Roslynator.Analyzers from 4.14.0 to 4.14.1 --- updated-dependencies: - dependency-name: Roslynator.Analyzers dependency-version: 4.14.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- src/PSTextMate.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index 2c04f8a..4df7fc9 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -19,7 +19,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 0cc914f331a4080a5fecc718da456a22f1322ce1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 06:19:24 +0000 Subject: [PATCH 04/25] streaming things --- .github/copilot-instructions.md | 142 ------------------- .gitignore | 3 + src/AssemblyInfo.cs | 4 + src/Compatibility/Converter.cs | 6 +- src/Core/Markdown/README.md | 15 +- src/Core/Markdown/Renderers/TableRenderer.cs | 2 +- src/Core/TextMateProcessor.cs | 28 +--- src/Helpers/StringBuilderPool.cs | 22 +++ 8 files changed, 51 insertions(+), 171 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 src/AssemblyInfo.cs create mode 100644 src/Helpers/StringBuilderPool.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index b361f07..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,142 +0,0 @@ - - ---- -description: 'Guidelines for building C# applications' -applyTo: '**/*.cs' ---- - -# C# Development - -## C# Instructions -- Always use the latest version C#, currently C# 13 features. -- Write clear and concise comments for each function. - -## General Instructions -- Make only high confidence suggestions when reviewing code changes. -- Write code with good maintainability practices, including comments on why certain design decisions were made. -- Handle edge cases and write clear exception handling. -- For libraries or external dependencies, mention their usage and purpose in comments. - -## Naming Conventions - -- Follow PascalCase for component names, method names, and public members. -- Use camelCase for private fields and local variables. -- Prefix interface names with "I" (e.g., IUserService). - -## Formatting - -- Apply code-formatting style defined in `.editorconfig`. -- Prefer file-scoped namespace declarations and single-line using directives. -- Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.). -- Ensure that the final return statement of a method is on its own line. -- Use pattern matching and switch expressions wherever possible. -- Use `nameof` instead of string literals when referring to member names. -- Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. - -## Project Setup and Structure - -- Guide users through creating a new .NET project with the appropriate templates. -- Explain the purpose of each generated file and folder to build understanding of the project structure. -- Demonstrate how to organize code using feature folders or domain-driven design principles. -- Show proper separation of concerns with models, services, and data access layers. -- Explain the Program.cs and configuration system in ASP.NET Core 9 including environment-specific settings. - -## Nullable Reference Types - -- Declare variables non-nullable, and check for `null` at entry points. -- Always use `is null` or `is not null` instead of `== null` or `!= null`. -- Trust the C# null annotations and don't add null checks when the type system says a value cannot be null. - -## Data Access Patterns - -- Guide the implementation of a data access layer using Entity Framework Core. -- Explain different options (SQL Server, SQLite, In-Memory) for development and production. -- Demonstrate repository pattern implementation and when it's beneficial. -- Show how to implement database migrations and data seeding. -- Explain efficient query patterns to avoid common performance issues. - -## Authentication and Authorization - -- Guide users through implementing authentication using JWT Bearer tokens. -- Explain OAuth 2.0 and OpenID Connect concepts as they relate to ASP.NET Core. -- Show how to implement role-based and policy-based authorization. -- Demonstrate integration with Microsoft Entra ID (formerly Azure AD). -- Explain how to secure both controller-based and Minimal APIs consistently. - -## Validation and Error Handling - -- Guide the implementation of model validation using data annotations and FluentValidation. -- Explain the validation pipeline and how to customize validation responses. -- Demonstrate a global exception handling strategy using middleware. -- Show how to create consistent error responses across the API. -- Explain problem details (RFC 7807) implementation for standardized error responses. - -## API Versioning and Documentation - -- Guide users through implementing and explaining API versioning strategies. -- Demonstrate Swagger/OpenAPI implementation with proper documentation. -- Show how to document endpoints, parameters, responses, and authentication. -- Explain versioning in both controller-based and Minimal APIs. -- Guide users on creating meaningful API documentation that helps consumers. - -## Logging and Monitoring - -- Guide the implementation of structured logging using Serilog or other providers. -- Explain the logging levels and when to use each. -- Demonstrate integration with Application Insights for telemetry collection. -- Show how to implement custom telemetry and correlation IDs for request tracking. -- Explain how to monitor API performance, errors, and usage patterns. - -## Testing - -- Always include test cases for critical paths of the application. -- Guide users through creating unit tests. -- Do not emit "Act", "Arrange" or "Assert" comments. -- Copy existing style in nearby files for test method names and capitalization. -- Explain integration testing approaches for API endpoints. -- Demonstrate how to mock dependencies for effective testing. -- Show how to test authentication and authorization logic. -- Explain test-driven development principles as applied to API development. - -## Performance Optimization - -- Guide users on implementing caching strategies (in-memory, distributed, response caching). -- Explain asynchronous programming patterns and why they matter for API performance. -- Demonstrate pagination, filtering, and sorting for large data sets. -- Show how to implement compression and other performance optimizations. -- Explain how to measure and benchmark API performance. - -## Deployment and DevOps - -- Guide users through containerizing their API using .NET's built-in container support (`dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer`). -- Explain the differences between manual Dockerfile creation and .NET's container publishing features. -- Explain CI/CD pipelines for NET applications. -- Demonstrate deployment to Azure App Service, Azure Container Apps, or other hosting options. -- Show how to implement health checks and readiness probes. -- Explain environment-specific configurations for different deployment stages. - - -### Target Framework -- **Target**: .NET 8.0 (`net8.0`) -- **Language Version**: C# 13.0 (latest features enabled) -- **Compatibility**: PowerShell 7.4+ -- **Benefits**: Modern performance, latest C# features, advanced optimization - -## Development Guidelines - -### PowerShell Best Practices -- Use proper PowerShell parameter validation attributes -- Include comprehensive help text for all parameters -- Handle errors gracefully with meaningful error messages -- Support pipeline input where appropriate (`ValueFromPipeline`, `ValueFromPipelineByPropertyName`), alias parameters where applicable to match property names on input objects -- Use `WriteVerbose` for debugging information -- Use `WriteWarning` for non-fatal issues -- Use `WriteError` for proper error handling with `ErrorRecord` -- Support `-WhatIf` and `-Confirm` parameters for cmdlets that modify state -- Use `OutputType` attribute to specify the type of output returned by cmdlets diff --git a/.gitignore b/.gitignore index 8746b0c..1e48f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ debug.md output/* */obj/* */bin/* +.github/chatmodes/* +.github/instructions/* +.github/prompts/* diff --git a/src/AssemblyInfo.cs b/src/AssemblyInfo.cs new file mode 100644 index 0000000..1febb66 --- /dev/null +++ b/src/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PSTextMate.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Compatibility/Converter.cs b/src/Compatibility/Converter.cs index 46c4035..b3e87b6 100644 --- a/src/Compatibility/Converter.cs +++ b/src/Compatibility/Converter.cs @@ -6,8 +6,10 @@ namespace PwshSpectreConsole.TextMate; public static class Converter { - public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) + public static Spectre.Console.Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { - return TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); + var rows = TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); + if (rows is null) return null; + return new Spectre.Console.Rows(rows.Renderables); } } diff --git a/src/Core/Markdown/README.md b/src/Core/Markdown/README.md index d3fe0c8..3e303d9 100644 --- a/src/Core/Markdown/README.md +++ b/src/Core/Markdown/README.md @@ -8,7 +8,7 @@ The markdown rendering functionality has been split into focused, single-respons ## Folder Structure -``` +```note src/Core/Markdown/ ├── MarkdownRenderer.cs # Main orchestrator ├── InlineProcessor.cs # Inline element processing @@ -27,14 +27,16 @@ src/Core/Markdown/ ## Component Responsibilities ### MarkdownRenderer + - **Purpose**: Main entry point for markdown rendering -- **Responsibilities**: +- **Responsibilities**: - Creates Markdig pipeline with extensions - Parses markdown document - Orchestrates block rendering - Manages spacing between elements ### InlineProcessor + - **Purpose**: Handles all inline markdown elements - **Responsibilities**: - Processes inline text extraction @@ -44,6 +46,7 @@ src/Core/Markdown/ - Applies theme-based styling ### BlockRenderer + - **Purpose**: Dispatches block elements to specific renderers - **Responsibilities**: - Pattern matches block types @@ -66,27 +69,32 @@ Each renderer handles a specific block type with focused responsibilities: ## Key Features ### Task List Support + - Detects `[x]`, `[X]`, and `[ ]` checkbox syntax - Renders with Unicode checkbox characters (☑️, ☐) - Automatically strips checkbox markup from displayed text ### Theme Integration + - Full TextMate theme support across all elements - Consistent color and styling application - Fallback styling for unsupported elements ### Performance Optimizations + - StringBuilder usage for efficient text building - Batch processing where possible - Minimal object allocation - Escape markup handling optimized per context ### Image Handling + - Special image link rendering with emoji indicators - Styled image descriptions - URL display for accessibility ### Code Highlighting + - TextMateProcessor integration for syntax highlighting - Language-specific panels with headers - Fallback rendering for unsupported languages @@ -95,9 +103,11 @@ Each renderer handles a specific block type with focused responsibilities: ## Migration Notes ### Backward Compatibility + The original `MarkdigSpectreMarkdownRenderer` class remains as a legacy wrapper that delegates to the new implementation, ensuring existing code continues to work without changes. ### Usage + ```csharp // New way (recommended) var result = MarkdownRenderer.Render(markdown, theme, themeName); @@ -118,6 +128,7 @@ var result = MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); ## Future Enhancements The modular architecture makes it easy to add: + - Custom block renderers - Additional inline element processors - Enhanced theme customization diff --git a/src/Core/Markdown/Renderers/TableRenderer.cs b/src/Core/Markdown/Renderers/TableRenderer.cs index 4dfdf60..f62b0f7 100644 --- a/src/Core/Markdown/Renderers/TableRenderer.cs +++ b/src/Core/Markdown/Renderers/TableRenderer.cs @@ -158,7 +158,7 @@ private static string ExtractCellTextOptimized(TableCell cell, Theme theme) } string result = textBuilder.ToString().Trim(); - TextMate.Helpers.StringBuilderPool.Return(textBuilder); + StringBuilderPool.Return(textBuilder); return result; } diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 6013f60..cdb9748 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -50,18 +50,8 @@ public static class TextMateProcessor try { (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); - var options = new TextMateSharp.Registry.RegistryOptions(themeName); - string initialScope = isExtension ? options.GetScopeByExtension(grammarId) : options.GetScopeByLanguageId(grammarId); - IGrammar? grammar = null; - try - { - grammar = registry.LoadGrammar(initialScope); - } - catch (TextMateSharp.TMException ex) - { - // Re-throw with a clearer message for our callers/tests - throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}", ex); - } + // Resolve grammar using CacheManager which knows how to map language ids and extensions + IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); if (grammar is null) { throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); @@ -172,18 +162,8 @@ public static IEnumerable ProcessLinesInBatches(IEnumerable _bag = []; + + public static StringBuilder Rent() + { + if (_bag.TryTake(out StringBuilder? sb)) return sb; + return new StringBuilder(); + } + + public static void Return(StringBuilder sb) + { + if (sb is null) return; + sb.Clear(); + _bag.Add(sb); + } +} From ac68e851d65be3ee449299aa8529ad199964da9a Mon Sep 17 00:00:00 2001 From: trackd Date: Thu, 30 Oct 2025 09:40:37 +0100 Subject: [PATCH 05/25] Refactor Markdown Rendering and Improve Performance - Replaced legacy string-based Markdown rendering with a modern Markdig-based renderer in MarkdownRenderer.cs for improved performance and reduced VT escape sequence issues. - Removed unused legacy rendering code and streamlined the Render method. - Updated Rows class to use an empty array initializer. - Enhanced StandardRenderer to utilize StringBuilder pooling for better memory management. - Refactored TextMateProcessor to support cancellation tokens and progress reporting in batch processing methods. - Updated TokenProcessor to handle escaping of markup characters and improved debugging capabilities. - Added comprehensive unit tests for StandardRenderer, TextMateProcessor, and TokenProcessor to ensure functionality and performance. - Cleaned up CacheManager tests to verify caching behavior and grammar retrieval. - Updated project file to generate documentation. --- Module/PSTextMate.psd1 | 2 +- build.ps1 | 2 +- src/Cmdlets/DebugCmdlets.cs | 58 +++++- src/Cmdlets/ShowTextMateCmdlet.cs | 37 +++- src/Cmdlets/SupportCmdlets.cs | 15 ++ src/Core/MarkdigSpectreMarkdownRenderer.cs | 25 --- src/Core/Markdown/InlineProcessor.cs | 2 +- src/Core/Markdown/Renderers/ImageRenderer.cs | 2 +- src/Core/MarkdownLinkFormatter.cs | 34 ---- src/Core/MarkdownRenderer.cs | 126 ++----------- src/Core/Rows.cs | 2 +- src/Core/StandardRenderer.cs | 35 ++-- src/Core/TextMateProcessor.cs | 129 +++++++++++--- src/Core/TokenProcessor.cs | 62 +------ src/Helpers/Completers.cs | 4 +- src/Helpers/ImageFile.cs | 2 +- src/PSTextMate.csproj | 1 + tests/Core/Markdown/TableRendererTests.cs | 1 + tests/Core/StandardRendererTests.cs | 92 ++++++++++ tests/Core/TextMateProcessorTests.cs | 168 ++++++++++++++++++ tests/Core/TokenProcessorTests.cs | 130 ++++++++++++++ tests/Infrastructure/CacheManagerTests.cs | 111 ++++++++++++ tests/Integration/TaskListIntegrationTests.cs | 13 +- tests/tests/Core/StandardRendererTests.cs | 0 tests/tests/Core/TextMateProcessorTests.cs | 0 tests/tests/Core/TokenProcessorTests.cs | 0 .../tests/Infrastructure/CacheManagerTests.cs | 0 27 files changed, 767 insertions(+), 286 deletions(-) delete mode 100644 src/Core/MarkdigSpectreMarkdownRenderer.cs delete mode 100644 src/Core/MarkdownLinkFormatter.cs create mode 100644 tests/Core/StandardRendererTests.cs create mode 100644 tests/Core/TextMateProcessorTests.cs create mode 100644 tests/Core/TokenProcessorTests.cs create mode 100644 tests/Infrastructure/CacheManagerTests.cs create mode 100644 tests/tests/Core/StandardRendererTests.cs create mode 100644 tests/tests/Core/TextMateProcessorTests.cs create mode 100644 tests/tests/Core/TokenProcessorTests.cs create mode 100644 tests/tests/Infrastructure/CacheManagerTests.cs diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index 5091b1e..fd0d64f 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSTextMate.dll' - ModuleVersion = '0.0.3' + ModuleVersion = '0.1.0' GUID = '5ba21f1d-5ca4-49df-9a07-a2ad379feb00' Author = 'trackd' CompanyName = 'trackd' diff --git a/build.ps1 b/build.ps1 index 6d85a34..017406e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -36,7 +36,7 @@ Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join- Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'linux-x64' | Join-Path -ChildPath 'native') -Filter *.so | Copy-Item -Destination $moduleLibFolder -Force Move-Item (Join-Path -Path $outputfolder -ChildPath 'PSTextMate.dll') -Destination (Split-Path $moduleLibFolder) -Force Get-ChildItem -Path $outputfolder -File | - Where-Object { -Not $_.Name.StartsWith('System.Text') -And $_.Extension -notin '.json','.pdb' } | + Where-Object { -Not $_.Name.StartsWith('System.Text') -And $_.Extension -notin '.json','.pdb','.xml' } | Move-Item -Destination $moduleLibFolder -Force Pop-Location diff --git a/src/Cmdlets/DebugCmdlets.cs b/src/Cmdlets/DebugCmdlets.cs index 3b0558c..3ff7f00 100644 --- a/src/Cmdlets/DebugCmdlets.cs +++ b/src/Cmdlets/DebugCmdlets.cs @@ -11,10 +11,14 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// Provides detailed diagnostic information for troubleshooting rendering issues. /// [Cmdlet(VerbsDiagnostic.Debug, "TextMate", DefaultParameterSetName = "String")] +[OutputType(typeof(Test.TextMateDebug))] public sealed class DebugTextMateCmdlet : PSCmdlet { private readonly List _inputObjectBuffer = new(); + /// + /// String content to debug. + /// [Parameter( Mandatory = true, ValueFromPipeline = true, @@ -23,6 +27,9 @@ public sealed class DebugTextMateCmdlet : PSCmdlet [AllowEmptyString] public string InputObject { get; set; } = null!; + /// + /// Path to file to debug. + /// [Parameter( Mandatory = true, ValueFromPipelineByPropertyName = true, @@ -33,15 +40,24 @@ public sealed class DebugTextMateCmdlet : PSCmdlet [Alias("FullName")] public string Path { get; set; } = null!; + /// + /// TextMate language ID (default: 'powershell'). + /// [Parameter( ParameterSetName = "String" )] [ValidateSet(typeof(TextMateLanguages))] public string Language { get; set; } = "powershell"; + /// + /// Color theme for debug output (default: Dark). + /// [Parameter()] public ThemeName Theme { get; set; } = ThemeName.Dark; + /// + /// Override file extension for language detection. + /// [Parameter( ParameterSetName = "Path" )] @@ -50,6 +66,9 @@ public sealed class DebugTextMateCmdlet : PSCmdlet [Alias("As")] public string ExtensionOverride { get; set; } = null!; + /// + /// Processes each input record from the pipeline. + /// protected override void ProcessRecord() { if (ParameterSetName == "String" && InputObject is not null) @@ -58,6 +77,9 @@ protected override void ProcessRecord() } } + /// + /// Finalizes processing and outputs debug information. + /// protected override void EndProcessing() { try @@ -98,33 +120,52 @@ protected override void EndProcessing() /// Cmdlet for debugging individual TextMate tokens and their properties. /// Provides low-level token analysis for detailed syntax highlighting inspection. /// +[OutputType(typeof(Core.TokenDebugInfo))] [Cmdlet(VerbsDiagnostic.Debug, "TextMateTokens", DefaultParameterSetName = "String")] public sealed class DebugTextMateTokensCmdlet : PSCmdlet { private readonly List _inputObjectBuffer = new(); + /// + /// String content to analyze tokens from. + /// [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String")] [AllowEmptyString] public string InputObject { get; set; } = null!; + /// + /// Path to file to analyze tokens from. + /// [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Path", Position = 0)] [ValidateNotNullOrEmpty] [Alias("FullName")] public string Path { get; set; } = null!; + /// + /// TextMate language ID (default: 'powershell'). + /// [Parameter(ParameterSetName = "String")] [ValidateSet(typeof(TextMateLanguages))] public string Language { get; set; } = "powershell"; + /// + /// Color theme for token analysis (default: DarkPlus). + /// [Parameter()] public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + /// + /// Override file extension for language detection. + /// [Parameter(ParameterSetName = "Path")] [TextMateExtensionTransform()] [ValidateSet(typeof(TextMateExtensions))] [Alias("As")] public string ExtensionOverride { get; set; } = null!; + /// + /// Processes each input record from the pipeline. + /// protected override void ProcessRecord() { if (ParameterSetName == "String" && InputObject is not null) @@ -133,6 +174,9 @@ protected override void ProcessRecord() } } + /// + /// Finalizes processing and outputs token debug information. + /// protected override void EndProcessing() { try @@ -176,6 +220,9 @@ protected override void EndProcessing() [Cmdlet(VerbsDiagnostic.Debug, "SixelSupport")] public sealed class DebugSixelSupportCmdlet : PSCmdlet { + /// + /// Processes the cmdlet and outputs Sixel support diagnostic information. + /// protected override void ProcessRecord() { try @@ -214,12 +261,21 @@ protected override void ProcessRecord() [Cmdlet(VerbsDiagnostic.Test, "ImageRendering")] public sealed class TestImageRenderingCmdlet : PSCmdlet { + /// + /// URL or path to image for rendering test. + /// [Parameter(Mandatory = true, Position = 0)] public string ImageUrl { get; set; } = null!; - [Parameter()] + /// + /// Alternative text for the image. + /// + [Parameter(Position = 1)] public string AltText { get; set; } = "Test Image"; + /// + /// Processes the cmdlet and tests image rendering. + /// protected override void ProcessRecord() { try diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index 612f783..81e95dc 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -13,22 +13,29 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// [Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "String")] [Alias("st","Show-Code")] -[OutputType(typeof(RenderableBatch))] +[OutputType(typeof(Spectre.Console.Rows), ParameterSetName = new[] { "String" })] +[OutputType(typeof(Spectre.Console.Rows), ParameterSetName = new[] { "Path" })] +[OutputType(typeof(RenderableBatch), ParameterSetName = new[] { "Path" })] public sealed class ShowTextMateCmdlet : PSCmdlet { private static readonly string[] NewLineSplit = ["\r\n", "\n", "\r"]; private readonly List _inputObjectBuffer = []; private string? _sourceExtensionHint; + /// + /// String content to render with syntax highlighting. + /// [Parameter( Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String" )] [AllowEmptyString] - [ValidateNotNull] - public string? InputObject { get; set; } + public string InputObject { get; set; } = string.Empty; + /// + /// Path to file to render with syntax highlighting. + /// [Parameter( Mandatory = true, ValueFromPipelineByPropertyName = true, @@ -37,8 +44,12 @@ public sealed class ShowTextMateCmdlet : PSCmdlet )] [ValidateNotNullOrEmpty] [Alias("FullName")] - public string? Path { get; set; } + public string Path { get; set; } = string.Empty; + /// + /// TextMate language ID for syntax highlighting (e.g., 'powershell', 'csharp', 'python'). + /// If not specified, detected from file extension or content. + /// [Parameter( ParameterSetName = "String" )] @@ -48,20 +59,35 @@ public sealed class ShowTextMateCmdlet : PSCmdlet [ArgumentCompleter(typeof(LanguageCompleter))] public string? Language { get; set; } + /// + /// Color theme to use for syntax highlighting. + /// [Parameter()] public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + /// + /// Returns the rendered output object instead of writing directly to host. + /// [Parameter] public SwitchParameter PassThru { get; set; } + /// + /// Enables streaming mode for large files, processing in batches. + /// [Parameter( ParameterSetName = "Path" )] public SwitchParameter Stream { get; set; } + /// + /// Number of lines to process per batch when streaming (default: 1000). + /// [Parameter(ParameterSetName = "Path")] public int BatchSize { get; set; } = 1000; + /// + /// Processes each input record from the pipeline. + /// protected override void ProcessRecord() { if (ParameterSetName == "String" && InputObject is not null) @@ -111,6 +137,9 @@ protected override void ProcessRecord() } } + /// + /// Finalizes processing after all pipeline records have been processed. + /// protected override void EndProcessing() { // For Path parameter set, each record is processed in ProcessRecord to support streaming multiple files. diff --git a/src/Cmdlets/SupportCmdlets.cs b/src/Cmdlets/SupportCmdlets.cs index 6ee5f6e..0818fd1 100644 --- a/src/Cmdlets/SupportCmdlets.cs +++ b/src/Cmdlets/SupportCmdlets.cs @@ -10,15 +10,27 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; [Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate")] public sealed class TestSupportedTextMateCmdlet : PSCmdlet { + /// + /// File extension to test for support (e.g., '.ps1'). + /// [Parameter()] public string? Extension { get; set; } + /// + /// Language ID to test for support (e.g., 'powershell'). + /// [Parameter()] public string? Language { get; set; } + /// + /// File path to test for support. + /// [Parameter()] public string? File { get; set; } + /// + /// Finalizes processing and outputs support check results. + /// protected override void EndProcessing() { if (!string.IsNullOrEmpty(File)) @@ -44,6 +56,9 @@ protected override void EndProcessing() [Cmdlet(VerbsCommon.Get, "SupportedTextMate")] public sealed class GetSupportedTextMateCmdlet : PSCmdlet { + /// + /// Finalizes processing and outputs all supported languages. + /// protected override void EndProcessing() { WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); diff --git a/src/Core/MarkdigSpectreMarkdownRenderer.cs b/src/Core/MarkdigSpectreMarkdownRenderer.cs deleted file mode 100644 index 7a17047..0000000 --- a/src/Core/MarkdigSpectreMarkdownRenderer.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Spectre.Console; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// Legacy wrapper for the refactored markdown renderer. -/// Now uses the renderer that builds Spectre.Console objects directly. -/// This eliminates VT escaping issues and improves performance. -/// -internal static class MarkdigSpectreMarkdownRenderer -{ - /// - /// Renders markdown content using the Spectre.Console object building approach. - /// - /// Markdown text (can be multi-line) - /// Theme object for styling - /// Theme name for TextMateProcessor - /// Rows object for Spectre.Console rendering - public static Rows Render(string markdown, Theme theme, ThemeName themeName) - { - return Markdown.MarkdownRenderer.Render(markdown, theme, themeName); - } -} diff --git a/src/Core/Markdown/InlineProcessor.cs b/src/Core/Markdown/InlineProcessor.cs index 2c16ed3..3b9f098 100644 --- a/src/Core/Markdown/InlineProcessor.cs +++ b/src/Core/Markdown/InlineProcessor.cs @@ -1,6 +1,6 @@ using System.Text; using Markdig.Syntax.Inlines; -using PwshSpectreConsole.TextMate.Core.Helpers; +using PwshSpectreConsole.TextMate.Helpers; using PwshSpectreConsole.TextMate.Extensions; using Spectre.Console; using TextMateSharp.Themes; diff --git a/src/Core/Markdown/Renderers/ImageRenderer.cs b/src/Core/Markdown/Renderers/ImageRenderer.cs index a978ca9..282c146 100644 --- a/src/Core/Markdown/Renderers/ImageRenderer.cs +++ b/src/Core/Markdown/Renderers/ImageRenderer.cs @@ -1,5 +1,5 @@ using System.Reflection; -using PwshSpectreConsole.TextMate.Core.Helpers; +using PwshSpectreConsole.TextMate.Helpers; using Spectre.Console; using Spectre.Console.Rendering; diff --git a/src/Core/MarkdownLinkFormatter.cs b/src/Core/MarkdownLinkFormatter.cs deleted file mode 100644 index 937bce2..0000000 --- a/src/Core/MarkdownLinkFormatter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Spectre.Console; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// Provides specialized formatting for Markdown elements. -/// Handles conversion of Markdown syntax to Spectre Console markup. -/// -internal static class MarkdownLinkFormatter -{ - /// - /// Creates a markdown link with Spectre Console markup. - /// - /// URL for the link - /// Display text for the link - /// Formatted link markup - public static string WriteMarkdownLink(string url, string linkText) - { - return $"[Blue link={url}]{linkText}[/] "; - } - - /// - /// Creates a markdown link with style information. - /// - /// URL for the link - /// Display text for the link - /// Tuple of formatted link and style - public static (string textEscaped, Style style) WriteMarkdownLinkWithStyle(string url, string linkText) - { - string mdlink = $"[link={url}]{Markup.Escape(linkText)}[/]"; - Style style = new(Color.Blue, Color.Default); - return (mdlink, style); - } -} diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index 6b4c137..edcc40d 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -1,133 +1,31 @@ -using System.Text; -using PwshSpectreConsole.TextMate.Extensions; -using Spectre.Console; -using Spectre.Console.Rendering; +using PwshSpectreConsole.TextMate.Core.Markdown; using TextMateSharp.Grammars; -using TextMateSharp.Model; using TextMateSharp.Themes; namespace PwshSpectreConsole.TextMate.Core; /// -/// Provides specialized rendering for Markdown content with enhanced link handling. -/// Includes special processing for Markdown links using Spectre Console link markup. +/// Provides specialized rendering for Markdown content using the modern Markdig-based renderer. +/// This facade delegates to the Core.Markdown.MarkdownRenderer which builds Spectre.Console objects directly. /// +/// +/// Legacy string-based renderer was removed in favor of the object-based Markdig renderer for better performance +/// and to eliminate VT escape sequence issues. +/// internal static class MarkdownRenderer { - - public static bool UseMarkdigRenderer { get; set; } = true; /// /// Renders Markdown content with special handling for links and enhanced formatting. /// /// Lines to render /// Theme to apply - /// Markdown grammar + /// Markdown grammar (used for compatibility, actual rendering uses Markdig) + /// Theme name for passing to Markdig renderer + /// Optional debug callback (not used by Markdig renderer) /// Rendered rows with markdown syntax highlighting - // Set this to true to use the new Markdig renderer, false for the legacy renderer - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName) - { - if (UseMarkdigRenderer) - { - string markdown = string.Join("\n", lines); - return MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); - } - else - { - return RenderLegacy(lines, theme, grammar, null); - } - } - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) { - if (UseMarkdigRenderer) - { - string markdown = string.Join("\n", lines); - return MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); - } - else - { - return RenderLegacy(lines, theme, grammar, debugCallback); - } - } - - // The original legacy renderer logic - private static Rows RenderLegacy(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) - { - var builder = new StringBuilder(); - List rows = new(lines.Length); - - IStateStack? ruleStack = null; - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) - { - string line = lines[lineIndex]; - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - ProcessMarkdownTokens(result.Tokens, line, theme, builder); - debugCallback?.Invoke(new TokenDebugInfo - { - LineIndex = lineIndex, - Text = line, - // You can add more fields if you refactor ProcessMarkdownTokens - }); - string? lineMarkup = builder.ToString(); - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); - builder.Clear(); - } - return new Rows(rows.ToArray()); - } - - /// - /// Processes markdown tokens with special handling for links. - /// - /// Tokens to process - /// Source line text - /// Theme for styling - /// StringBuilder for output - private static void ProcessMarkdownTokens(IToken[] tokens, string line, Theme theme, StringBuilder builder) - { - string? url = null; - string? title = null; - - for (int i = 0; i < tokens.Length; i++) - { - IToken token = tokens[i]; - - if (token.Scopes.Contains("meta.link.inline.markdown")) - { - i++; // Skip first bracket token - while (i < tokens.Length && tokens[i].Scopes.Contains("meta.link.inline.markdown")) - { - if (tokens[i].Scopes.Contains("string.other.link.title.markdown")) - { - title = line.SubstringAtIndexes(tokens[i].StartIndex, tokens[i].EndIndex); - } - if (tokens[i].Scopes.Contains("markup.underline.link.markdown")) - { - url = line.SubstringAtIndexes(tokens[i].StartIndex, tokens[i].EndIndex); - } - if (title is not null && url is not null) - { - builder.Append(MarkdownLinkFormatter.WriteMarkdownLink(url, title)); - title = null; - url = null; - } - i++; - } - continue; - } - - int startIndex = Math.Min(token.StartIndex, line.Length); - int endIndex = Math.Min(token.EndIndex, line.Length); - - if (startIndex >= endIndex) continue; - - ReadOnlySpan textSpan = line.SubstringAsSpan(startIndex, endIndex); - - // Use the cached Style where possible to avoid rebuilding Style objects per token - Style? style = TokenProcessor.GetStyleForScopes(token.Scopes, theme); - - // Append escaped/unstyled text directly to the provided StringBuilder - TokenProcessor.WriteTokenOptimized(builder, textSpan, style, theme, escapeMarkup: true); - } + string markdown = string.Join("\n", lines); + return Markdown.MarkdownRenderer.Render(markdown, theme, themeName); } } diff --git a/src/Core/Rows.cs b/src/Core/Rows.cs index b359078..ddb76f1 100644 --- a/src/Core/Rows.cs +++ b/src/Core/Rows.cs @@ -7,5 +7,5 @@ namespace PwshSpectreConsole.TextMate.Core; /// public sealed record Rows(IRenderable[] Renderables) { - public static Rows Empty { get; } = new Rows(Array.Empty()); + public static Rows Empty { get; } = new Rows([]); } diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 91b4cfc..50fafd7 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -3,6 +3,7 @@ using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core; @@ -27,32 +28,36 @@ public static Rows Render(string[] lines, Theme theme, IGrammar grammar) public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) { - var builder = new StringBuilder(); + var builder = StringBuilderPool.Rent(); List rows = new(lines.Length); try { - IStateStack? ruleStack = null; - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) - { - string line = lines[lineIndex]; - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, debugCallback, lineIndex); - string? lineMarkup = builder.ToString(); - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); - builder.Clear(); - } + IStateStack? ruleStack = null; + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) + { + string line = lines[lineIndex]; + ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); + ruleStack = result.RuleStack; + TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, debugCallback, lineIndex); + string? lineMarkup = builder.ToString(); + rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); + builder.Clear(); + } - return new Rows([.. rows]); + return new Rows([.. rows]); } catch (ArgumentException ex) { - throw new InvalidOperationException($"Argument error rendering content: {ex.Message}", ex); + throw new InvalidOperationException($"Argument error during rendering: {ex.Message}", ex); } catch (Exception ex) { - throw new InvalidOperationException($"Unexpected error rendering content: {ex.Message}", ex); + throw new InvalidOperationException($"Unexpected error during rendering: {ex.Message}", ex); + } + finally + { + StringBuilderPool.Return(builder); } } } diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index cdb9748..b92cafe 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -1,6 +1,7 @@ using System.Text; using PwshSpectreConsole.TextMate.Infrastructure; using PwshSpectreConsole.TextMate.Extensions; +using PwshSpectreConsole.TextMate.Helpers; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; @@ -23,9 +24,8 @@ public static class TextMateProcessor /// Language ID or file extension for grammar selection /// True if grammarId is a file extension, false if it's a language ID /// Rendered rows with syntax highlighting, or null if processing fails - /// Thrown when lines array is null - /// Thrown when grammar cannot be found - /// Thrown when processing encounters an error + /// Thrown when is null + /// Thrown when grammar cannot be found or processing encounters an error public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); @@ -85,6 +85,8 @@ public static class TextMateProcessor /// Language ID or file extension for grammar selection /// True if grammarId is a file extension, false if it's a language ID /// Rendered rows with syntax highlighting, or null if processing fails + /// Thrown when is null + /// Thrown when grammar cannot be found or processing encounters an error public static Rows? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); @@ -124,31 +126,63 @@ public static class TextMateProcessor /// private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) { - var builder = new StringBuilder(); - List rows = new(lines.Length); - IStateStack? ruleStack = null; + var builder = StringBuilderPool.Rent(); + try + { + List rows = new(lines.Length); + IStateStack? ruleStack = null; + + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) + { + string line = lines[lineIndex]; + ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); + ruleStack = result.RuleStack; + TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, debugCallback: null, lineIndex, escapeMarkup: false); + string lineMarkup = builder.ToString(); + // Use Text (raw content) for code blocks so markup characters are preserved + // and not interpreted by the Markup parser. + rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Text(lineMarkup)); + builder.Clear(); + } - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) + return new Rows(rows.ToArray()); + } + finally { - string line = lines[lineIndex]; - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - TokenProcessor.ProcessTokensBatchNoEscape(result.Tokens, line, theme, builder, null, lineIndex); - string lineMarkup = builder.ToString(); - // Use Text (raw content) for code blocks so markup characters are preserved - // and not interpreted by the Markup parser. - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Text(lineMarkup)); - builder.Clear(); + StringBuilderPool.Return(builder); } - - return new Rows(rows.ToArray()); } /// /// Processes an enumerable of lines in batches to support streaming/low-memory processing. /// Yields a Rows result for each processed batch. /// - public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + /// Enumerable of text lines to process + /// Number of lines to process per batch (default: 1000 lines balances memory usage with throughput) + /// Theme to apply for styling + /// Language ID or file extension for grammar selection + /// True if grammarId is a file extension, false if it's a language ID + /// Token to monitor for cancellation requests + /// Optional progress reporter for tracking processing status + /// Enumerable of RenderableBatch objects containing processed lines + /// Thrown when is null + /// Thrown when is less than or equal to zero + /// Thrown when grammar cannot be found + /// Thrown when cancellation is requested + /// + /// Batch size considerations: + /// - Smaller batches (100-500): Lower memory, more frequent progress updates, slightly higher overhead + /// - Default (1000): Balanced approach for most scenarios + /// - Larger batches (2000-5000): Better throughput for large files, higher memory usage + /// + public static IEnumerable ProcessLinesInBatches( + IEnumerable lines, + int batchSize, + ThemeName themeName, + string grammarId, + bool isExtension = false, + CancellationToken cancellationToken = default, + IProgress<(int batchIndex, long linesProcessed)>? progress = null) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize, nameof(batchSize)); @@ -173,6 +207,8 @@ public static IEnumerable ProcessLinesInBatches(IEnumerable= batchSize) { @@ -181,27 +217,74 @@ public static IEnumerable ProcessLinesInBatches(IEnumerable 0) { + cancellationToken.ThrowIfCancellationRequested(); + Rows? result = useMarkdownRenderer ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) : StandardRenderer.Render([.. buffer], theme, grammar, null); if (result is not null) - yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex++, fileOffset: fileOffset); + { + yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex, fileOffset: fileOffset); + progress?.Report((batchIndex, fileOffset + buffer.Count)); + } } } + /// + /// Backward compatibility overload without cancellation and progress support. + /// + public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + { + return ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, CancellationToken.None, null); + } + /// /// Helper to stream a file by reading lines lazily and processing them in batches. /// - public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + /// Path to the file to process + /// Number of lines to process per batch + /// Theme to apply for styling + /// Language ID or file extension for grammar selection + /// True if grammarId is a file extension, false if it's a language ID + /// Token to monitor for cancellation requests + /// Optional progress reporter for tracking processing status + /// Enumerable of RenderableBatch objects containing processed lines + /// Thrown when the specified file does not exist + /// Thrown when lines enumerable is null + /// Thrown when batchSize is less than or equal to zero + /// Thrown when grammar cannot be found + /// Thrown when cancellation is requested + public static IEnumerable ProcessFileInBatches( + string filePath, + int batchSize, + ThemeName themeName, + string grammarId, + bool isExtension = false, + CancellationToken cancellationToken = default, + IProgress<(int batchIndex, long linesProcessed)>? progress = null) { if (!File.Exists(filePath)) throw new FileNotFoundException(filePath); - return ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension); + return ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension, cancellationToken, progress); + } + + /// + /// Backward compatibility overload without cancellation and progress support. + /// + public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + { + return ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, CancellationToken.None, null); } } diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index 4973930..ae04097 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -30,13 +30,17 @@ internal static class TokenProcessor /// Source line text /// Theme for styling /// StringBuilder for output + /// Optional callback for debugging + /// Line index for debugging context + /// Whether to escape markup characters (true for normal text, false for code blocks) public static void ProcessTokensBatch( IToken[] tokens, string line, Theme theme, StringBuilder builder, Action? debugCallback = null, - int? lineIndex = null) + int? lineIndex = null, + bool escapeMarkup = true) { foreach (IToken token in tokens) { @@ -58,7 +62,7 @@ public static void ProcessTokensBatch( } // Use the returning API so callers can append with style consistently (prevents markup regressions) - (string processedText, Style? resolvedStyle) = WriteTokenOptimizedReturn(textSpan, style, theme, escapeMarkup: true); + (string processedText, Style? resolvedStyle) = WriteTokenOptimizedReturn(textSpan, style, theme, escapeMarkup); builder.AppendWithStyle(resolvedStyle, processedText); debugCallback?.Invoke(new TokenDebugInfo @@ -77,60 +81,6 @@ public static void ProcessTokensBatch( } } - /// - /// Processes tokens from TextMate grammar tokenization without escaping markup. - /// Used for code blocks where we want to preserve raw content. - /// - /// Tokens to process - /// Source line text - /// Theme for color resolution - /// StringBuilder to append styled text to - /// Optional callback for debugging token information - /// Line index for debugging context - public static void ProcessTokensBatchNoEscape( - IToken[] tokens, - string line, - Theme theme, - StringBuilder builder, - Action? debugCallback = null, - int? lineIndex = null) - { - foreach (IToken token in tokens) - { - int startIndex = Math.Min(token.StartIndex, line.Length); - int endIndex = Math.Min(token.EndIndex, line.Length); - - if (startIndex >= endIndex) continue; - - ReadOnlySpan textSpan = line.SubstringAsSpan(startIndex, endIndex); - - Style? style = GetStyleForScopes(token.Scopes, theme); - - (int foreground, int background, FontStyle fontStyle) = ( -1, -1, FontStyle.NotSet ); - if (debugCallback is not null) - { - (foreground, background, fontStyle) = ExtractThemeProperties(token, theme); - } - - // Use the span-based writer to append raw text without additional escaping - WriteTokenOptimized(builder, textSpan, style, theme, escapeMarkup: false); - - debugCallback?.Invoke(new TokenDebugInfo - { - LineIndex = lineIndex, - StartIndex = startIndex, - EndIndex = endIndex, - Text = line.SubstringAtIndexes(startIndex, endIndex), - Scopes = token.Scopes, - Foreground = foreground, - Background = background, - FontStyle = fontStyle, - Style = style, - Theme = theme.GetGuiColorDictionary() - }); - } - } - public static (int foreground, int background, FontStyle fontStyle) ExtractThemeProperties(IToken token, Theme theme) { // Build a compact key from token scopes (they're mostly immutable per token) diff --git a/src/Helpers/Completers.cs b/src/Helpers/Completers.cs index 9b2f66a..790d849 100644 --- a/src/Helpers/Completers.cs +++ b/src/Helpers/Completers.cs @@ -47,7 +47,7 @@ bool Match(string token) if (!wantsExtensionsOnly) { // Languages first - foreach (string lang in TextMateHelper.Languages ?? Array.Empty()) + foreach (string lang in TextMateHelper.Languages ?? []) { if (!Match(lang)) continue; results.Add(new CompletionResult( @@ -59,7 +59,7 @@ bool Match(string token) } // Extensions (always include if requested or no leading '.') - foreach (string ext in TextMateHelper.Extensions ?? Array.Empty()) + foreach (string ext in TextMateHelper.Extensions ?? []) { if (!Match(ext)) continue; string completion = ext; // keep dot in completion diff --git a/src/Helpers/ImageFile.cs b/src/Helpers/ImageFile.cs index a7755f0..eb37637 100644 --- a/src/Helpers/ImageFile.cs +++ b/src/Helpers/ImageFile.cs @@ -8,7 +8,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace PwshSpectreConsole.TextMate.Core.Helpers; +namespace PwshSpectreConsole.TextMate.Helpers; /// /// Normalizes various image sources (file paths, URLs, base64) into file paths that can be used by Spectre.Console.SixelImage. diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index 4df7fc9..de41f45 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -8,6 +8,7 @@ 13.0 Recommended true + true diff --git a/tests/Core/Markdown/TableRendererTests.cs b/tests/Core/Markdown/TableRendererTests.cs index 3646dfb..87db19e 100644 --- a/tests/Core/Markdown/TableRendererTests.cs +++ b/tests/Core/Markdown/TableRendererTests.cs @@ -3,6 +3,7 @@ using Markdig.Syntax; using Markdig.Extensions.Tables; using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +using TextMateSharp.Grammars; using TextMateSharp.Themes; namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; diff --git a/tests/Core/StandardRendererTests.cs b/tests/Core/StandardRendererTests.cs new file mode 100644 index 0000000..7f2f847 --- /dev/null +++ b/tests/Core/StandardRendererTests.cs @@ -0,0 +1,92 @@ +using PwshSpectreConsole.TextMate.Core; +using PwshSpectreConsole.TextMate.Infrastructure; +using TextMateSharp.Grammars; + +namespace PwshSpectreConsole.TextMate.Tests.Core; + +public class StandardRendererTests +{ + [Fact] + public void Render_WithValidInput_ReturnsRows() + { + // Arrange + string[] lines = ["function Test-Function {", " Write-Host 'Hello'", "}"]; + var (grammar, theme) = GetTestGrammarAndTheme(); + + // Act + var result = StandardRenderer.Render(lines, theme, grammar); + + // Assert + result.Should().NotBeNull(); + result.Renderables.Should().HaveCount(3); + } + + [Fact] + public void Render_WithEmptyLines_HandlesGracefully() + { + // Arrange + string[] lines = ["", "test", ""]; + var (grammar, theme) = GetTestGrammarAndTheme(); + + // Act + var result = StandardRenderer.Render(lines, theme, grammar); + + // Assert + result.Should().NotBeNull(); + result.Renderables.Should().HaveCount(3); + } + + [Fact] + public void Render_WithSingleLine_ReturnsOneRow() + { + // Arrange + string[] lines = ["$x = 1"]; + var (grammar, theme) = GetTestGrammarAndTheme(); + + // Act + var result = StandardRenderer.Render(lines, theme, grammar); + + // Assert + result.Should().NotBeNull(); + result.Renderables.Should().HaveCount(1); + } + + [Fact] + public void Render_WithDebugCallback_InvokesCallback() + { + // Arrange + string[] lines = ["$x = 1"]; + var (grammar, theme) = GetTestGrammarAndTheme(); + var debugInfos = new List(); + + // Act + var result = StandardRenderer.Render(lines, theme, grammar, info => debugInfos.Add(info)); + + // Assert + result.Should().NotBeNull(); + debugInfos.Should().NotBeEmpty(); + } + + [Fact] + public void Render_PreservesLineOrder() + { + // Arrange + string[] lines = ["# Line 1", "# Line 2", "# Line 3"]; + var (grammar, theme) = GetTestGrammarAndTheme(); + + // Act + var result = StandardRenderer.Render(lines, theme, grammar); + + // Assert + result.Should().NotBeNull(); + result.Renderables.Should().HaveCount(3); + result.Renderables.Should().ContainInOrder(result.Renderables); + } + + private static (IGrammar grammar, Theme theme) GetTestGrammarAndTheme() + { + var (registry, theme) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + var grammar = CacheManager.GetCachedGrammar(registry, "powershell", isExtension: false); + return (grammar!, theme); + } +} diff --git a/tests/Core/TextMateProcessorTests.cs b/tests/Core/TextMateProcessorTests.cs new file mode 100644 index 0000000..2128885 --- /dev/null +++ b/tests/Core/TextMateProcessorTests.cs @@ -0,0 +1,168 @@ +using PwshSpectreConsole.TextMate.Core; +using TextMateSharp.Grammars; + +namespace PwshSpectreConsole.TextMate.Tests.Core; + +public class TextMateProcessorTests +{ + [Fact] + public void ProcessLines_WithValidInput_ReturnsRows() + { + // Arrange + string[] lines = ["$x = 1", "$y = 2"]; + + // Act + var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "powershell", isExtension: false); + + // Assert + result.Should().NotBeNull(); + result!.Renderables.Should().HaveCount(2); + } + + [Fact] + public void ProcessLines_WithEmptyArray_ReturnsNull() + { + // Arrange + string[] lines = []; + + // Act + var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "powershell", isExtension: false); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ProcessLines_WithNullInput_ThrowsArgumentNullException() + { + // Arrange + string[] lines = null!; + + // Act + Action act = () => TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "powershell", isExtension: false); + + // Assert + act.Should().Throw() + .WithParameterName("lines"); + } + + [Fact] + public void ProcessLines_WithInvalidGrammar_ThrowsInvalidOperationException() + { + // Arrange + string[] lines = ["test"]; + + // Act + Action act = () => TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "invalid-grammar-xyz", isExtension: false); + + // Assert + act.Should().Throw() + .WithMessage("*Grammar not found*"); + } + + [Fact] + public void ProcessLines_WithExtension_ResolvesGrammar() + { + // Arrange + string[] lines = ["function Test { }"]; + + // Act + var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, ".ps1", isExtension: true); + + // Assert + result.Should().NotBeNull(); + result!.Renderables.Should().HaveCount(1); + } + + [Fact] + public void ProcessLinesCodeBlock_PreservesRawContent() + { + // Arrange + string[] lines = ["", "[brackets]"]; + + // Act + var result = TextMateProcessor.ProcessLinesCodeBlock(lines, ThemeName.DarkPlus, "html", isExtension: false); + + // Assert + result.Should().NotBeNull(); + result!.Renderables.Should().HaveCount(2); + } + + [Fact] + public void ProcessLinesInBatches_WithValidInput_YieldsBatches() + { + // Arrange + var lines = Enumerable.Range(1, 100).Select(i => $"Line {i}"); + int batchSize = 25; + + // Act + var batches = TextMateProcessor.ProcessLinesInBatches(lines, batchSize, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); + + // Assert + batches.Should().HaveCount(4); + batches[0].BatchIndex.Should().Be(0); + batches[0].FileOffset.Should().Be(0); + batches[1].BatchIndex.Should().Be(1); + batches[1].FileOffset.Should().Be(25); + } + + [Fact] + public void ProcessLinesInBatches_WithInvalidBatchSize_ThrowsArgumentOutOfRangeException() + { + // Arrange + var lines = new[] { "test" }; + + // Act + Action act = () => TextMateProcessor.ProcessLinesInBatches(lines, 0, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); + + // Assert + act.Should().Throw() + .WithParameterName("batchSize"); + } + + [Fact] + public void ProcessFileInBatches_WithNonExistentFile_ThrowsFileNotFoundException() + { + // Arrange + string filePath = "non-existent-file.txt"; + + // Act + Action act = () => TextMateProcessor.ProcessFileInBatches(filePath, 100, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData("csharp")] + [InlineData("python")] + [InlineData("javascript")] + [InlineData("markdown")] + public void ProcessLines_WithDifferentLanguages_Succeeds(string language) + { + // Arrange + string[] lines = ["// comment", "var x = 1;"]; + + // Act + var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, language, isExtension: false); + + // Assert + result.Should().NotBeNull(); + } + + [Theory] + [InlineData(ThemeName.DarkPlus)] + [InlineData(ThemeName.Light)] + [InlineData(ThemeName.Monokai)] + public void ProcessLines_WithDifferentThemes_Succeeds(ThemeName theme) + { + // Arrange + string[] lines = ["$x = 1"]; + + // Act + var result = TextMateProcessor.ProcessLines(lines, theme, "powershell", isExtension: false); + + // Assert + result.Should().NotBeNull(); + } +} diff --git a/tests/Core/TokenProcessorTests.cs b/tests/Core/TokenProcessorTests.cs new file mode 100644 index 0000000..1e1db06 --- /dev/null +++ b/tests/Core/TokenProcessorTests.cs @@ -0,0 +1,130 @@ +using System.Text; +using PwshSpectreConsole.TextMate.Core; +using PwshSpectreConsole.TextMate.Infrastructure; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Tests.Core; + +public class TokenProcessorTests +{ + [Fact] + public void ProcessTokensBatch_WithEscapeMarkup_EscapesSpecialCharacters() + { + // Arrange + var (grammar, theme) = GetTestGrammarAndTheme(); + string line = "[markup] "; + var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); + var builder = new StringBuilder(); + + // Act + TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: true); + string result = builder.ToString(); + + // Assert - markup should be escaped + (result.Contains("[[") || result.Contains("]]") || result.Contains("<")).Should().BeTrue(); + } + + [Fact] + public void ProcessTokensBatch_WithoutEscapeMarkup_PreservesRawContent() + { + // Arrange + var (grammar, theme) = GetTestGrammarAndTheme(); + string line = "[markup]"; + var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); + var builder = new StringBuilder(); + + // Act + TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: false); + string result = builder.ToString(); + + // Assert - when not escaping, raw brackets should be in result + result.Should().NotBeEmpty(); + } + + [Fact] + public void ProcessTokensBatch_WithDebugCallback_InvokesCallback() + { + // Arrange + var (grammar, theme) = GetTestGrammarAndTheme(); + string line = "$x = 1"; + var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); + var builder = new StringBuilder(); + var debugInfos = new List(); + + // Act + TokenProcessor.ProcessTokensBatch( + tokenResult.Tokens, + line, + theme, + builder, + debugCallback: info => debugInfos.Add(info), + lineIndex: 0, + escapeMarkup: true + ); + + // Assert + debugInfos.Should().NotBeEmpty(); + debugInfos[0].LineIndex.Should().Be(0); + debugInfos[0].Text.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void ExtractThemeProperties_CachesResults() + { + // Arrange + var (grammar, theme) = GetTestGrammarAndTheme(); + string line = "$x = 1"; + var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); + var firstToken = tokenResult.Tokens[0]; + + // Act - call twice with same token + var result1 = TokenProcessor.ExtractThemeProperties(firstToken, theme); + var result2 = TokenProcessor.ExtractThemeProperties(firstToken, theme); + + // Assert - both calls should return same cached result + result1.Should().Be(result2); + } + + [Fact] + public void ProcessTokensBatch_WithEmptyLine_HandlesGracefully() + { + // Arrange + var (grammar, theme) = GetTestGrammarAndTheme(); + string line = ""; + var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); + var builder = new StringBuilder(); + + // Act + TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: true); + string result = builder.ToString(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ProcessTokensBatch_WithMultipleTokens_ProcessesAll() + { + // Arrange + var (grammar, theme) = GetTestGrammarAndTheme(); + string line = "$variable = 'string'"; + var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); + var builder = new StringBuilder(); + + // Act + TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: true); + string result = builder.ToString(); + + // Assert + result.Should().NotBeEmpty(); + result.Length.Should().BeGreaterThan(0); + } + + private static (IGrammar grammar, Theme theme) GetTestGrammarAndTheme() + { + var (registry, theme) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + var grammar = CacheManager.GetCachedGrammar(registry, "powershell", isExtension: false); + return (grammar!, theme); + } +} diff --git a/tests/Infrastructure/CacheManagerTests.cs b/tests/Infrastructure/CacheManagerTests.cs new file mode 100644 index 0000000..5336aba --- /dev/null +++ b/tests/Infrastructure/CacheManagerTests.cs @@ -0,0 +1,111 @@ +using PwshSpectreConsole.TextMate.Infrastructure; +using TextMateSharp.Grammars; + +namespace PwshSpectreConsole.TextMate.Tests.Infrastructure; + +public class CacheManagerTests +{ + [Fact] + public void GetCachedTheme_ReturnsSameInstanceOnRepeatedCalls() + { + // Arrange + var themeName = ThemeName.DarkPlus; + + // Act + var (registry1, theme1) = CacheManager.GetCachedTheme(themeName); + var (registry2, theme2) = CacheManager.GetCachedTheme(themeName); + + // Assert + registry1.Should().BeSameAs(registry2); + theme1.Should().BeSameAs(theme2); + } + + [Theory] + [InlineData(ThemeName.DarkPlus)] + [InlineData(ThemeName.Light)] + [InlineData(ThemeName.Monokai)] + public void GetCachedTheme_WorksForAllThemes(ThemeName themeName) + { + // Act + var (registry, theme) = CacheManager.GetCachedTheme(themeName); + + // Assert + registry.Should().NotBeNull(); + theme.Should().NotBeNull(); + } + + [Fact] + public void GetCachedGrammar_ReturnsSameInstanceOnRepeatedCalls() + { + // Arrange + var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + string grammarId = "powershell"; + + // Act + var grammar1 = CacheManager.GetCachedGrammar(registry, grammarId, isExtension: false); + var grammar2 = CacheManager.GetCachedGrammar(registry, grammarId, isExtension: false); + + // Assert + grammar1.Should().BeSameAs(grammar2); + } + + [Fact] + public void GetCachedGrammar_WithExtension_LoadsCorrectGrammar() + { + // Arrange + var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + string extension = ".ps1"; + + // Act + var grammar = CacheManager.GetCachedGrammar(registry, extension, isExtension: true); + + // Assert + grammar.Should().NotBeNull(); + grammar!.GetName().Should().Be("PowerShell"); + } + + [Fact] + public void GetCachedGrammar_WithLanguageId_LoadsCorrectGrammar() + { + // Arrange + var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + string languageId = "csharp"; + + // Act + var grammar = CacheManager.GetCachedGrammar(registry, languageId, isExtension: false); + + // Assert + grammar.Should().NotBeNull(); + } + + [Fact] + public void GetCachedGrammar_WithInvalidGrammar_ReturnsNull() + { + // Arrange + var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + string invalidGrammar = "invalid-grammar-xyz"; + + // Act + var grammar = CacheManager.GetCachedGrammar(registry, invalidGrammar, isExtension: false); + + // Assert + grammar.Should().BeNull(); + } + + [Fact] + public void ClearCache_RemovesAllCachedItems() + { + // Arrange + var (registry1, theme1) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + var grammar1 = CacheManager.GetCachedGrammar(registry1, "powershell", isExtension: false); + + // Act + CacheManager.ClearCache(); + var (registry2, theme2) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + var grammar2 = CacheManager.GetCachedGrammar(registry2, "powershell", isExtension: false); + + // Assert - new instances after clear + registry1.Should().NotBeSameAs(registry2); + theme1.Should().NotBeSameAs(theme2); + } +} diff --git a/tests/Integration/TaskListIntegrationTests.cs b/tests/Integration/TaskListIntegrationTests.cs index 88c726f..52ae4e8 100644 --- a/tests/Integration/TaskListIntegrationTests.cs +++ b/tests/Integration/TaskListIntegrationTests.cs @@ -1,6 +1,7 @@ using PwshSpectreConsole.TextMate.Core; using System.Threading; using TextMateSharp.Grammars; +using MarkdownRenderer = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer; namespace PwshSpectreConsole.TextMate.Tests.Integration; @@ -11,7 +12,7 @@ namespace PwshSpectreConsole.TextMate.Tests.Integration; public class TaskListIntegrationTests { [Fact] - public void MarkdigSpectreMarkdownRenderer_TaskList_ProducesCorrectCheckboxes() + public void MarkdownRenderer_TaskList_ProducesCorrectCheckboxes() { // Arrange var markdown = """ @@ -27,7 +28,7 @@ public void MarkdigSpectreMarkdownRenderer_TaskList_ProducesCorrectCheckboxes() var themeName = ThemeName.DarkPlus; // Act - var result = MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); + var result = MarkdownRenderer.Render(markdown, theme, themeName); // Assert result.Should().NotBeNull(); @@ -48,21 +49,21 @@ public void MarkdigSpectreMarkdownRenderer_TaskList_ProducesCorrectCheckboxes() [InlineData("- [ ] Incomplete", false)] [InlineData("- [X] Uppercase completed", true)] [InlineData("- Regular item", false)] - public void MarkdigSpectreMarkdownRenderer_VariousTaskListFormats_RendersWithoutErrors(string markdown, bool isTaskList) + public void MarkdownRenderer_VariousTaskListFormats_RendersWithoutErrors(string markdown, bool isTaskList) { // Arrange var theme = CreateTestTheme(); var themeName = ThemeName.DarkPlus; // Act & Assert - Should not throw exceptions - var result = MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); + var result = MarkdownRenderer.Render(markdown, theme, themeName); result.Should().NotBeNull(); result.Renderables.Should().NotBeEmpty(); } [Fact] - public void MarkdigSpectreMarkdownRenderer_ComplexTaskList_RendersWithoutReflectionErrors() + public void MarkdownRenderer_ComplexTaskList_RendersWithoutReflectionErrors() { // Arrange var markdown = """ @@ -86,7 +87,7 @@ public void MarkdigSpectreMarkdownRenderer_ComplexTaskList_RendersWithoutReflect var themeName = ThemeName.DarkPlus; // Act & Assert - This would fail with reflection errors if not fixed - var result = MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); + var result = MarkdownRenderer.Render(markdown, theme, themeName); result.Should().NotBeNull(); result.Renderables.Should().NotBeEmpty(); diff --git a/tests/tests/Core/StandardRendererTests.cs b/tests/tests/Core/StandardRendererTests.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests/Core/TextMateProcessorTests.cs b/tests/tests/Core/TextMateProcessorTests.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests/Core/TokenProcessorTests.cs b/tests/tests/Core/TokenProcessorTests.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests/Infrastructure/CacheManagerTests.cs b/tests/tests/Infrastructure/CacheManagerTests.cs new file mode 100644 index 0000000..e69de29 From db9ad4c442acb871c44e8745dc7e65ba5c9de064 Mon Sep 17 00:00:00 2001 From: trackd Date: Tue, 6 Jan 2026 04:35:07 +0100 Subject: [PATCH 06/25] =?UTF-8?q?feat(helpers):=20=E2=9C=A8=20Enhance=20de?= =?UTF-8?q?bugging=20and=20image=20handling=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improved `Debug.cs` with detailed XML documentation for `TextMateDebug` class. * Refactored `DebugTextMate` and `DebugTextMateTokens` methods for better readability and performance. * Updated `Helpers.cs` to include XML documentation for `TextMateHelper` class and its members. * Enhanced `ImageFile.cs` with partial methods for regex and improved image source normalization. * Streamlined `StringBuilderPool.cs` for concise string builder management. * Updated `TextMateResolver.cs` with improved token resolution logic. * Refined `VTConversion.cs` for better parsing of VT escape sequences and style application. * Optimized `CacheManager.cs` for efficient caching of themes and grammars. * Updated project dependencies in `PSTextMate.csproj` for better compatibility. * Added a new demo script `demo-textmate.ps1` for showcasing TextMate functionality. --- .editorconfig | 291 +++++++++--------- .gitignore | 2 +- src/Cmdlets/DebugCmdlets.cs | 109 +++---- src/Cmdlets/ShowTextMateCmdlet.cs | 111 +++---- src/Cmdlets/SupportCmdlets.cs | 23 +- src/Compatibility/Converter.cs | 22 +- src/Core/MarkdigTextMateScopeMapper.cs | 30 +- src/Core/Markdown/InlineProcessor.cs | 61 ++-- src/Core/Markdown/MarkdownRenderer.cs | 24 +- .../SpanOptimizedMarkdownProcessor.cs | 68 ++-- src/Core/Markdown/Renderers/BlockRenderer.cs | 9 +- .../Markdown/Renderers/CodeBlockRenderer.cs | 88 ++---- .../Markdown/Renderers/HeadingRenderer.cs | 66 ++-- .../Renderers/HorizontalRuleRenderer.cs | 8 +- .../Markdown/Renderers/HtmlBlockRenderer.cs | 24 +- src/Core/Markdown/Renderers/ImageRenderer.cs | 184 ++++------- src/Core/Markdown/Renderers/ListRenderer.cs | 125 +++----- .../Markdown/Renderers/ParagraphRenderer.cs | 138 +++------ src/Core/Markdown/Renderers/QuoteRenderer.cs | 18 +- src/Core/Markdown/Renderers/TableRenderer.cs | 129 +++----- src/Core/Markdown/Types/MarkdownTypes.cs | 26 +- src/Core/MarkdownRenderer.cs | 6 +- src/Core/MarkdownToken.cs | 10 +- src/Core/RenderableBatch.cs | 54 ++-- src/Core/Rows.cs | 6 +- src/Core/StandardRenderer.cs | 30 +- src/Core/StyleHelper.cs | 21 +- src/Core/TextMateProcessor.cs | 132 +++----- src/Core/TokenDebugInfo.cs | 36 ++- src/Core/TokenProcessor.cs | 109 +++---- src/Core/Validation/MarkdownInputValidator.cs | 32 +- .../SpanOptimizedStringExtensions.cs | 46 +-- src/Extensions/StringBuilderExtensions.cs | 71 +++-- src/Extensions/StringExtensions.cs | 25 +- src/Extensions/ThemeExtensions.cs | 31 +- src/Helpers/Completers.cs | 107 ++++--- src/Helpers/Debug.cs | 81 ++++- src/Helpers/Helpers.cs | 24 +- src/Helpers/ImageFile.cs | 103 +++---- src/Helpers/StringBuilderPool.cs | 12 +- src/Helpers/TextMateResolver.cs | 15 +- src/Helpers/VTConversion.cs | 229 +++++--------- src/Infrastructure/CacheManager.cs | 18 +- src/PSTextMate.csproj | 12 +- tests/demo-textmate.ps1 | 123 ++++++++ tests/test-markdown-rendering.md | 25 +- 46 files changed, 1278 insertions(+), 1636 deletions(-) create mode 100644 tests/demo-textmate.ps1 diff --git a/.editorconfig b/.editorconfig index 37222d5..df302c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,167 +1,180 @@ -# EditorConfig is awesome: https://EditorConfig.org - -# top-most EditorConfig file root = true -# All files -[*] -charset = utf-8 -end_of_line = crlf -insert_final_newline = true -trim_trailing_whitespace = true +# Consolidated .editorconfig for FileWatchRest +# - Modern C#/.NET 10 friendly defaults +# - Analyzer baseline: keep most suggestions non-breaking, escalate high-value performance/security rules to errors +# - Preserve relaxed analyzer severity for test projects -# Code files -[*.{cs,csx,vb,vbx}] -indent_style = space -indent_size = 4 +############################################################ +# Basic file-type formatting +############################################################ -# XML project files -[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] -indent_style = space +[*.{json,yaml,yml}] indent_size = 2 -# XML files -[*.{xml,stylecop,resx,ruleset}] -indent_style = space +[*.md] indent_size = 2 -# JSON files -[*.{json,json5,webmanifest}] -indent_style = space +[*.{resx,ruleset,stylecop,xml,xsd,xsl}] indent_size = 2 -# YAML files -[*.{yml,yaml}] -indent_style = space +[*.{props,targets,config,nuspec}] indent_size = 2 -# Markdown files -[*.md] -trim_trailing_whitespace = false - -# Web files -[*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,svg,vue}] -indent_style = space -indent_size = 2 +[*.tsv] +indent_style = tab -# Bash scripts -[*.sh] -end_of_line = lf +############################################################ +# Standard formatting for C# and project files +############################################################ -# PowerShell files -[*.{ps1,psm1,psd1}] +[*.{cs,csproj}] indent_style = space indent_size = 4 +charset = utf-8 +end_of_line = crlf +insert_final_newline = true + +############################################################ +# C# / .NET defaults - consolidated for clarity +############################################################ -# C# code style rules [*.cs] -# Organize usings +# Set severity for all analyzers that are enabled by default (https://docs.microsoft.com/en-us/visualstudio/code-quality/use-roslyn-analyzers?view=vs-2022#set-rule-severity-of-multiple-analyzer-rules-at-once-in-an-editorconfig-file) +dotnet_analyzer_diagnostic.category-roslynator.severity = default|none|silent|suggestion|warning|error + +# Enable/disable all analyzers by default +# NOTE: This option can be used only in .roslynatorconfig file +roslynator_analyzers.enabled_by_default = true + +# Set severity for a specific analyzer +# dotnet_diagnostic..severity = default|none|silent|suggestion|warning|error + +# Enable/disable all refactorings +roslynator_refactorings.enabled = true + +# Enable/disable specific refactoring +# roslynator_refactoring..enabled = true|false + +# Enable/disable all compiler diagnostic fixes +roslynator_compiler_diagnostic_fixes.enabled = true + +# Enable/disable specific compiler diagnostic fix +# roslynator_compiler_diagnostic_fix..enabled = true|false + +# Sort using directives with System.* first dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = false - -# this. preferences -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:suggestion - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_predefined_type_for_member_access = true:suggestion - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion -dotnet_style_readonly_field = true:suggestion - -# Expression-level preferences -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_style_prefer_conditional_expression_over_return = true:silent - -# C# preferences -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion - -# Inlined variable declarations -csharp_style_inlined_variable_declaration = true:suggestion - -# Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion - -# C# formatting rules -csharp_new_line_before_open_brace = all + +# Default analyzer severity for most rules: suggestion (non-breaking for local dev) +dotnet_analyzer_diagnostic.severity = suggestion + +# categories: sensible defaults for repository quality +# Style: formatting and stylistic suggestions +dotnet_analyzer_diagnostic.category-Style.severity = warning + +# Performance: high-value performance rules treated as errors (e.g. hot-path logging) +dotnet_analyzer_diagnostic.category-Performance.severity = error + +# Naming: recommendations; keep as suggestions to avoid developer friction +dotnet_analyzer_diagnostic.category-Naming.severity = suggestion + +# Maintainability: suggestions for cleaner, maintainable code +dotnet_analyzer_diagnostic.category-Maintainability.severity = suggestion + +# Interoperability: warn about platform/interop issues +dotnet_analyzer_diagnostic.category-Interoperability.severity = warning + +# Design: API design and usage guidance +dotnet_analyzer_diagnostic.category-Design.severity = warning + +# Documentation: XML/docs guidance as suggestions +dotnet_analyzer_diagnostic.category-Documentation.severity = suggestion + +# Globalization: treat important globalization issues as errors (e.g. culture-sensitive formatting) +dotnet_analyzer_diagnostic.category-Globalization.severity = error + +# Reliability: lifecycle/dispose and other reliability concerns - warn by default +dotnet_analyzer_diagnostic.category-Reliability.severity = warning + +# Security: escalate security findings to errors +dotnet_analyzer_diagnostic.category-Security.severity = error + +# Usage: general API usage guidance +dotnet_analyzer_diagnostic.category-Usage.severity = warning + +# Modern C# style preferences (good defaults for .NET 10) +csharp_style_namespace_declarations = file_scoped +csharp_style_implicit_object_creation = true +csharp_style_target_typed_new_expression = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_not_pattern = true + +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_inlined_variable_declaration = true +csharp_style_throw_expression = true +csharp_style_prefer_switch_expression = true +csharp_prefer_simple_using_statement = true +csharp_style_prefer_pattern_matching = true +dotnet_style_operator_placement_when_wrapping = end_of_line + +# # Prefer pattern-matching null checks (`is null` / `is not null`) over `== null` or ReferenceEquals +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_diagnostic.IDE0041.severity = warning +# Also enable related null-check simplification rules +dotnet_diagnostic.IDE0029.severity = warning +dotnet_diagnostic.IDE0030.severity = warning +dotnet_diagnostic.IDE0270.severity = warning +dotnet_diagnostic.IDE0019.severity = warning + +# # Prefer var when the type is apparent (modern and concise) +csharp_style_var_when_type_is_apparent = true + +# # Expression-bodied members where concise +csharp_style_expression_bodied_methods = when_on_single_line +csharp_style_expression_bodied_properties = when_on_single_line + +# # Naming conventions (kept as suggestions) +# dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion +# dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +# dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore +# dotnet_naming_symbols.private_fields.applicable_kinds = field +# dotnet_naming_symbols.private_fields.applicable_accessibilities = private +# dotnet_naming_style.camel_case_underscore.capitalization = camel_case +# dotnet_naming_style.camel_case_underscore.required_prefix = _ +# csharp_style_unused_value_expression_statement_preference = discard_variable + + +# Helpful IDE rules as suggestions so dotnet-format can apply fixes +dotnet_diagnostic.IDE0005.severity = suggestion # Remove unnecessary usings +dotnet_diagnostic.IDE0059.severity = suggestion # Unused assignment +dotnet_diagnostic.IDE0051.severity = suggestion # Unused private members +dotnet_diagnostic.IDE0060.severity = suggestion # Unused parameters +dotnet_diagnostic.IDE0058.severity = suggestion # Expression value is never used +dotnet_diagnostic.IDE0130.severity = suggestion # Use 'new' expression where possible (target-typed new) + +# Nullable reference types - enabled as suggestions; project opt-in controls runtime enforcement +nullable = enable + +# Formatting / newline preferences +# prefer Stroustrup +csharp_new_line_before_open_brace = false csharp_new_line_before_else = true csharp_new_line_before_catch = true csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true - +csharp_prefer_braces = when_multiline # Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true csharp_indent_switch_labels = true -csharp_indent_labels = flush_left - -# Space preferences -csharp_space_after_cast = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_around_binary_operators = before_and_after -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false - -# Wrapping preferences -csharp_preserve_single_line_statements = true -csharp_preserve_single_line_blocks = true - -# Code analysis rules -[*.cs] -# CA1303: Do not pass literals as localized parameters -dotnet_diagnostic.CA1303.severity = none - -# CA1062: Validate arguments of public methods -dotnet_diagnostic.CA1062.severity = suggestion +csharp_indent_labels = one_less_than_current -# CA1031: Do not catch general exception types -dotnet_diagnostic.CA1031.severity = suggestion +# Var and explicit typing preferences +# csharp_style_var_for_built_in_types = false:none +# csharp_style_var_when_type_is_apparent = true:suggestion +# csharp_style_var_elsewhere = false:suggestion -# CA2007: Consider calling ConfigureAwait on the awaited task -dotnet_diagnostic.CA2007.severity = none +# Using directive placement +csharp_using_directive_placement = outside_namespace diff --git a/.gitignore b/.gitignore index 1e48f4e..b4a277c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ src/obj/* src/.vs/* Module/lib debug.md -output/* +[Oo]utput/ */obj/* */bin/* .github/chatmodes/* diff --git a/src/Cmdlets/DebugCmdlets.cs b/src/Cmdlets/DebugCmdlets.cs index 3ff7f00..3144263 100644 --- a/src/Cmdlets/DebugCmdlets.cs +++ b/src/Cmdlets/DebugCmdlets.cs @@ -1,8 +1,8 @@ using System.Management.Automation; -using TextMateSharp.Grammars; -using PwshSpectreConsole.TextMate.Extensions; using PwshSpectreConsole.TextMate.Core; +using PwshSpectreConsole.TextMate.Extensions; using Spectre.Console.Rendering; +using TextMateSharp.Grammars; namespace PwshSpectreConsole.TextMate.Cmdlets; @@ -12,9 +12,8 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// [Cmdlet(VerbsDiagnostic.Debug, "TextMate", DefaultParameterSetName = "String")] [OutputType(typeof(Test.TextMateDebug))] -public sealed class DebugTextMateCmdlet : PSCmdlet -{ - private readonly List _inputObjectBuffer = new(); +public sealed class DebugTextMateCmdlet : PSCmdlet { + private readonly List _inputObjectBuffer = []; /// /// String content to debug. @@ -69,10 +68,8 @@ public sealed class DebugTextMateCmdlet : PSCmdlet /// /// Processes each input record from the pipeline. /// - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && InputObject is not null) - { + protected override void ProcessRecord() { + if (ParameterSetName == "String" && InputObject is not null) { _inputObjectBuffer.Add(InputObject); } } @@ -80,25 +77,19 @@ protected override void ProcessRecord() /// /// Finalizes processing and outputs debug information. /// - protected override void EndProcessing() - { - try - { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) - { - string[] strings = _inputObjectBuffer.ToArray(); - if (strings.AllIsNullOrEmpty()) - { + protected override void EndProcessing() { + try { + if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) { + string[] strings = [.. _inputObjectBuffer]; + if (strings.AllIsNullOrEmpty()) { return; } Test.TextMateDebug[]? obj = Test.DebugTextMate(strings, Theme, Language); WriteObject(obj, true); } - else if (ParameterSetName == "Path" && Path is not null) - { + else if (ParameterSetName == "Path" && Path is not null) { FileInfo Filepath = new(GetUnresolvedProviderPathFromPSPath(Path)); - if (!Filepath.Exists) - { + if (!Filepath.Exists) { throw new FileNotFoundException("File not found", Filepath.FullName); } string ext = !string.IsNullOrEmpty(ExtensionOverride) @@ -109,8 +100,7 @@ protected override void EndProcessing() WriteObject(obj, true); } } - catch (Exception ex) - { + catch (Exception ex) { WriteError(new ErrorRecord(ex, "DebugTextMateError", ErrorCategory.InvalidOperation, null)); } } @@ -120,11 +110,10 @@ protected override void EndProcessing() /// Cmdlet for debugging individual TextMate tokens and their properties. /// Provides low-level token analysis for detailed syntax highlighting inspection. /// -[OutputType(typeof(Core.TokenDebugInfo))] +[OutputType(typeof(TokenDebugInfo))] [Cmdlet(VerbsDiagnostic.Debug, "TextMateTokens", DefaultParameterSetName = "String")] -public sealed class DebugTextMateTokensCmdlet : PSCmdlet -{ - private readonly List _inputObjectBuffer = new(); +public sealed class DebugTextMateTokensCmdlet : PSCmdlet { + private readonly List _inputObjectBuffer = []; /// /// String content to analyze tokens from. @@ -166,10 +155,8 @@ public sealed class DebugTextMateTokensCmdlet : PSCmdlet /// /// Processes each input record from the pipeline. /// - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && InputObject is not null) - { + protected override void ProcessRecord() { + if (ParameterSetName == "String" && InputObject is not null) { _inputObjectBuffer.Add(InputObject); } } @@ -177,25 +164,19 @@ protected override void ProcessRecord() /// /// Finalizes processing and outputs token debug information. /// - protected override void EndProcessing() - { - try - { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) - { + protected override void EndProcessing() { + try { + if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) { string[] strings = [.. _inputObjectBuffer]; - if (strings.AllIsNullOrEmpty()) - { + if (strings.AllIsNullOrEmpty()) { return; } TokenDebugInfo[]? obj = Test.DebugTextMateTokens(strings, Theme, Language); WriteObject(obj, true); } - else if (ParameterSetName == "Path" && Path is not null) - { + else if (ParameterSetName == "Path" && Path is not null) { FileInfo Filepath = new(GetUnresolvedProviderPathFromPSPath(Path)); - if (!Filepath.Exists) - { + if (!Filepath.Exists) { throw new FileNotFoundException("File not found", Filepath.FullName); } string ext = !string.IsNullOrEmpty(ExtensionOverride) @@ -206,8 +187,7 @@ protected override void EndProcessing() WriteObject(obj, true); } } - catch (Exception ex) - { + catch (Exception ex) { WriteError(new ErrorRecord(ex, "DebugTextMateTokensError", ErrorCategory.InvalidOperation, null)); } } @@ -218,26 +198,21 @@ protected override void EndProcessing() /// Provides diagnostic information about Sixel capabilities in the current environment. /// [Cmdlet(VerbsDiagnostic.Debug, "SixelSupport")] -public sealed class DebugSixelSupportCmdlet : PSCmdlet -{ +public sealed class DebugSixelSupportCmdlet : PSCmdlet { /// /// Processes the cmdlet and outputs Sixel support diagnostic information. /// - protected override void ProcessRecord() - { - try - { - var result = new - { + protected override void ProcessRecord() { + try { + var result = new { SixelImageAvailable = Core.Markdown.Renderers.ImageRenderer.IsSixelImageAvailable(), LastSixelError = Core.Markdown.Renderers.ImageRenderer.GetLastSixelError(), LoadedAssemblies = AppDomain.CurrentDomain.GetAssemblies() .Where(a => a.GetName().Name?.Contains("Spectre.Console") == true) - .Select(a => new - { - Name = a.GetName().Name, + .Select(a => new { + a.GetName().Name, Version = a.GetName().Version?.ToString(), - Location = a.Location, + a.Location, SixelTypes = a.GetTypes() .Where(t => t.Name.Contains("Sixel", StringComparison.OrdinalIgnoreCase)) .Select(t => t.FullName) @@ -248,8 +223,7 @@ protected override void ProcessRecord() WriteObject(result); } - catch (Exception ex) - { + catch (Exception ex) { WriteError(new ErrorRecord(ex, "DebugSixelSupportError", ErrorCategory.InvalidOperation, null)); } } @@ -259,8 +233,7 @@ protected override void ProcessRecord() /// Cmdlet for testing image rendering and debugging issues. /// [Cmdlet(VerbsDiagnostic.Test, "ImageRendering")] -public sealed class TestImageRenderingCmdlet : PSCmdlet -{ +public sealed class TestImageRenderingCmdlet : PSCmdlet { /// /// URL or path to image for rendering test. /// @@ -276,16 +249,13 @@ public sealed class TestImageRenderingCmdlet : PSCmdlet /// /// Processes the cmdlet and tests image rendering. /// - protected override void ProcessRecord() - { - try - { + protected override void ProcessRecord() { + try { WriteVerbose($"Testing image rendering for: {ImageUrl}"); IRenderable result = Core.Markdown.Renderers.ImageRenderer.RenderImage(AltText, ImageUrl); - var debugInfo = new - { + var debugInfo = new { ImageUrl, AltText, ResultType = result.GetType().FullName, @@ -298,8 +268,7 @@ protected override void ProcessRecord() WriteObject("Rendered result:"); WriteObject(result); } - catch (Exception ex) - { + catch (Exception ex) { WriteError(new ErrorRecord(ex, "TestImageRenderingError", ErrorCategory.InvalidOperation, ImageUrl)); } } diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index 81e95dc..c3d9ce2 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -1,9 +1,9 @@ using System.Management.Automation; -using TextMateSharp.Grammars; -using PwshSpectreConsole.TextMate.Extensions; -using Spectre.Console; using PwshSpectreConsole.TextMate; using PwshSpectreConsole.TextMate.Core; +using PwshSpectreConsole.TextMate.Extensions; +using Spectre.Console; +using TextMateSharp.Grammars; namespace PwshSpectreConsole.TextMate.Cmdlets; @@ -12,12 +12,11 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// Supports both string input and file processing with theme customization. /// [Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "String")] -[Alias("st","Show-Code")] +[Alias("st", "Show-Code")] [OutputType(typeof(Spectre.Console.Rows), ParameterSetName = new[] { "String" })] [OutputType(typeof(Spectre.Console.Rows), ParameterSetName = new[] { "Path" })] [OutputType(typeof(RenderableBatch), ParameterSetName = new[] { "Path" })] -public sealed class ShowTextMateCmdlet : PSCmdlet -{ +public sealed class ShowTextMateCmdlet : PSCmdlet { private static readonly string[] NewLineSplit = ["\r\n", "\n", "\r"]; private readonly List _inputObjectBuffer = []; private string? _sourceExtensionHint; @@ -88,25 +87,19 @@ public sealed class ShowTextMateCmdlet : PSCmdlet /// /// Processes each input record from the pipeline. /// - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && InputObject is not null) - { + protected override void ProcessRecord() { + if (ParameterSetName == "String" && InputObject is not null) { // Try to capture an extension hint from ETS note properties on the current pipeline object // (e.g., PSChildName/PSPath added by Get-Content) - if (_sourceExtensionHint is null) - { - if (GetVariableValue("_") is PSObject current) - { + if (_sourceExtensionHint is null) { + if (GetVariableValue("_") is PSObject current) { string? hint = current.Properties["PSChildName"]?.Value as string ?? current.Properties["PSPath"]?.Value as string ?? current.Properties["Path"]?.Value as string ?? current.Properties["FullName"]?.Value as string; - if (!string.IsNullOrWhiteSpace(hint)) - { + if (!string.IsNullOrWhiteSpace(hint)) { string ext = System.IO.Path.GetExtension(hint); - if (!string.IsNullOrWhiteSpace(ext)) - { + if (!string.IsNullOrWhiteSpace(ext)) { _sourceExtensionHint = ext; } } @@ -116,23 +109,18 @@ protected override void ProcessRecord() return; } - if (ParameterSetName == "Path" && !string.IsNullOrWhiteSpace(Path)) - { - try - { + if (ParameterSetName == "Path" && !string.IsNullOrWhiteSpace(Path)) { + try { Spectre.Console.Rows? result = ProcessPathInput(); - if (result is not null) - { + if (result is not null) { WriteObject(result); - if (PassThru) - { + if (PassThru) { WriteVerbose($"Processed file '{Path}' with theme '{Theme}' {(string.IsNullOrWhiteSpace(Language) ? "(by extension)" : $"(token: {Language})")}"); } } } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, Path!)); + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, Path)); } } } @@ -140,92 +128,75 @@ protected override void ProcessRecord() /// /// Finalizes processing after all pipeline records have been processed. /// - protected override void EndProcessing() - { + protected override void EndProcessing() { // For Path parameter set, each record is processed in ProcessRecord to support streaming multiple files. // Only finalize buffered String input here. - if (ParameterSetName != "String") - { + if (ParameterSetName != "String") { return; } - try - { + try { Spectre.Console.Rows? result = ProcessStringInput(); - if (result is not null) - { + if (result is not null) { WriteObject(result); - if (PassThru) - { + if (PassThru) { WriteVerbose($"Processed {_inputObjectBuffer.Count} lines with theme '{Theme}' {(string.IsNullOrWhiteSpace(Language) ? "(by hint/default)" : $"(token: {Language})")}"); } } } - catch (Exception ex) - { + catch (Exception ex) { WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); } } - private Spectre.Console.Rows? ProcessStringInput() - { - if (_inputObjectBuffer.Count == 0) - { + private Spectre.Console.Rows? ProcessStringInput() { + if (_inputObjectBuffer.Count == 0) { WriteVerbose("No input provided"); return null; } string[] strings = [.. _inputObjectBuffer]; // If only one string and it contains any newline, split it into lines for correct rendering - if (strings.Length == 1 && (strings[0].Contains('\n') || strings[0].Contains('\r'))) - { + if (strings.Length == 1 && (strings[0].Contains('\n') || strings[0].Contains('\r'))) { strings = strings[0].Split(NewLineSplit, StringSplitOptions.None); } - if (strings.AllIsNullOrEmpty()) - { + if (strings.AllIsNullOrEmpty()) { WriteVerbose("All input strings are null or empty"); return null; } // If a Language token was provided, resolve it first (language id or extension) - if (!string.IsNullOrWhiteSpace(Language)) - { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language!); + if (!string.IsNullOrWhiteSpace(Language)) { + (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language); return Converter.ProcessLines(strings, Theme, token, isExtension: asExtension); } // Otherwise prefer extension hint from ETS (PSChildName/PSPath) - if (!string.IsNullOrWhiteSpace(_sourceExtensionHint)) - { - return Converter.ProcessLines(strings, Theme, _sourceExtensionHint!, isExtension: true); + if (!string.IsNullOrWhiteSpace(_sourceExtensionHint)) { + return Converter.ProcessLines(strings, Theme, _sourceExtensionHint, isExtension: true); } // Final fallback: default language return Converter.ProcessLines(strings, Theme, "powershell", isExtension: false); } - private Spectre.Console.Rows? ProcessPathInput() - { + private Spectre.Console.Rows? ProcessPathInput() { FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(Path)); - if (!filePath.Exists) - { + if (!filePath.Exists) { throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); } // Decide how to interpret based on precedence: // 1) Language token (can be a language id OR an extension) // 2) File extension - if (Stream.IsPresent) - { + if (Stream.IsPresent) { // Stream file in batches int batchIndex = 0; - if (!string.IsNullOrWhiteSpace(Language)) - { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language!); + if (!string.IsNullOrWhiteSpace(Language)) { + (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language); WriteVerbose($"Streaming file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")}) in batches of {BatchSize}"); - foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension)) - { + foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension)) { // Attach a stable batch index so consumers can track ordering var indexed = new RenderableBatch(batch.Renderables, batchIndex: batchIndex++, fileOffset: batch.FileOffset); WriteObject(indexed); @@ -235,8 +206,7 @@ protected override void EndProcessing() string extension = filePath.Extension; WriteVerbose($"Streaming file: {filePath.FullName} using file extension: {extension} in batches of {BatchSize}"); - foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, extension, true)) - { + foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, extension, true)) { var indexed = new RenderableBatch(batch.Renderables, batchIndex: batchIndex++, fileOffset: batch.FileOffset); WriteObject(indexed); } @@ -244,9 +214,8 @@ protected override void EndProcessing() } string[] lines = File.ReadAllLines(filePath.FullName); - if (!string.IsNullOrWhiteSpace(Language)) - { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language!); + if (!string.IsNullOrWhiteSpace(Language)) { + (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language); WriteVerbose($"Processing file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")})"); return Converter.ProcessLines(lines, Theme, token, isExtension: asExtension); } diff --git a/src/Cmdlets/SupportCmdlets.cs b/src/Cmdlets/SupportCmdlets.cs index 0818fd1..49292b6 100644 --- a/src/Cmdlets/SupportCmdlets.cs +++ b/src/Cmdlets/SupportCmdlets.cs @@ -8,8 +8,7 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// Provides validation functionality to check compatibility before processing. /// [Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate")] -public sealed class TestSupportedTextMateCmdlet : PSCmdlet -{ +public sealed class TestSupportedTextMateCmdlet : PSCmdlet { /// /// File extension to test for support (e.g., '.ps1'). /// @@ -31,18 +30,14 @@ public sealed class TestSupportedTextMateCmdlet : PSCmdlet /// /// Finalizes processing and outputs support check results. /// - protected override void EndProcessing() - { - if (!string.IsNullOrEmpty(File)) - { + protected override void EndProcessing() { + if (!string.IsNullOrEmpty(File)) { WriteObject(TextMateExtensions.IsSupportedFile(File)); } - if (!string.IsNullOrEmpty(Extension)) - { + if (!string.IsNullOrEmpty(Extension)) { WriteObject(TextMateExtensions.IsSupportedExtension(Extension)); } - if (!string.IsNullOrEmpty(Language)) - { + if (!string.IsNullOrEmpty(Language)) { WriteObject(TextMateLanguages.IsSupportedLanguage(Language)); } } @@ -54,13 +49,9 @@ protected override void EndProcessing() /// [OutputType(typeof(Language))] [Cmdlet(VerbsCommon.Get, "SupportedTextMate")] -public sealed class GetSupportedTextMateCmdlet : PSCmdlet -{ +public sealed class GetSupportedTextMateCmdlet : PSCmdlet { /// /// Finalizes processing and outputs all supported languages. /// - protected override void EndProcessing() - { - WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); - } + protected override void EndProcessing() => WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); } diff --git a/src/Compatibility/Converter.cs b/src/Compatibility/Converter.cs index b3e87b6..4b7d324 100644 --- a/src/Compatibility/Converter.cs +++ b/src/Compatibility/Converter.cs @@ -4,12 +4,20 @@ namespace PwshSpectreConsole.TextMate; -public static class Converter -{ - public static Spectre.Console.Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) - { - var rows = TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); - if (rows is null) return null; - return new Spectre.Console.Rows(rows.Renderables); +/// +/// +/// +public static class Converter { + /// + /// + /// + /// + /// + /// + /// + /// + public static Spectre.Console.Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { + Core.Rows? rows = TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); + return rows is null ? null : new Spectre.Console.Rows(rows.Renderables); } } diff --git a/src/Core/MarkdigTextMateScopeMapper.cs b/src/Core/MarkdigTextMateScopeMapper.cs index f127a34..7ea517e 100644 --- a/src/Core/MarkdigTextMateScopeMapper.cs +++ b/src/Core/MarkdigTextMateScopeMapper.cs @@ -3,8 +3,7 @@ namespace PwshSpectreConsole.TextMate.Core; /// /// Maps Markdig markdown element types to TextMate scopes for theme lookup. /// -internal static class MarkdigTextMateScopeMapper -{ +internal static class MarkdigTextMateScopeMapper { private static readonly Dictionary BlockScopeMap = new() { { "Heading1", new[] { "markup.heading.1.markdown", "markup.heading.markdown" } }, @@ -38,29 +37,20 @@ internal static class MarkdigTextMateScopeMapper { "LineBreak", new[] { "text.whitespace" } }, }; - public static string[] GetBlockScopes(string blockType, int headingLevel = 0) - { - if (blockType == "Heading" && headingLevel > 0 && headingLevel <= 6) - return BlockScopeMap[$"Heading{headingLevel}"]; - if (BlockScopeMap.TryGetValue(blockType, out string[]? scopes)) - return scopes; - return ["text.plain"]; + public static string[] GetBlockScopes(string blockType, int headingLevel = 0) { + return blockType == "Heading" && headingLevel > 0 && headingLevel <= 6 + ? BlockScopeMap[$"Heading{headingLevel}"] + : BlockScopeMap.TryGetValue(blockType, out string[]? scopes) ? scopes : ["text.plain"]; } - public static string[] GetInlineScopes(string inlineType, int emphasisLevel = 0) - { - if (inlineType == "Emphasis") - { - return emphasisLevel switch - { + public static string[] GetInlineScopes(string inlineType, int emphasisLevel = 0) { + return inlineType == "Emphasis" + ? emphasisLevel switch { 1 => InlineScopeMap["EmphasisItalic"], 2 => InlineScopeMap["EmphasisBold"], 3 => InlineScopeMap["EmphasisBoldItalic"], _ => ["text.plain"] - }; - } - if (InlineScopeMap.TryGetValue(inlineType, out string[]? scopes)) - return scopes; - return ["text.plain"]; + } + : InlineScopeMap.TryGetValue(inlineType, out string[]? scopes) ? scopes : ["text.plain"]; } } diff --git a/src/Core/Markdown/InlineProcessor.cs b/src/Core/Markdown/InlineProcessor.cs index 3b9f098..0335d08 100644 --- a/src/Core/Markdown/InlineProcessor.cs +++ b/src/Core/Markdown/InlineProcessor.cs @@ -1,7 +1,7 @@ using System.Text; using Markdig.Syntax.Inlines; -using PwshSpectreConsole.TextMate.Helpers; using PwshSpectreConsole.TextMate.Extensions; +using PwshSpectreConsole.TextMate.Helpers; using Spectre.Console; using TextMateSharp.Themes; @@ -10,22 +10,18 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown; /// /// Handles extraction and styling of inline markdown elements. /// -internal static class InlineProcessor -{ +internal static class InlineProcessor { /// /// Extracts and styles inline text from Markdig inline elements. /// /// Container holding inline elements /// Theme for styling /// StringBuilder to append results to - public static void ExtractInlineText(ContainerInline? container, Theme theme, StringBuilder builder) - { + public static void ExtractInlineText(ContainerInline? container, Theme theme, StringBuilder builder) { if (container is null) return; - foreach (Inline inline in container) - { - switch (inline) - { + foreach (Inline inline in container) { + switch (inline) { case LiteralInline literal: ProcessLiteralInline(literal, builder); break; @@ -57,8 +53,7 @@ public static void ExtractInlineText(ContainerInline? container, Theme theme, St /// /// Processes literal text inline elements. /// - private static void ProcessLiteralInline(LiteralInline literal, StringBuilder builder) - { + private static void ProcessLiteralInline(LiteralInline literal, StringBuilder builder) { ReadOnlySpan span = literal.Content.Text.AsSpan(literal.Content.Start, literal.Content.Length); builder.Append(span); } @@ -66,24 +61,19 @@ private static void ProcessLiteralInline(LiteralInline literal, StringBuilder bu /// /// Processes link and image inline elements. /// - private static void ProcessLinkInline(LinkInline link, Theme theme, StringBuilder builder) - { - if (!string.IsNullOrEmpty(link.Url)) - { + private static void ProcessLinkInline(LinkInline link, Theme theme, StringBuilder builder) { + if (!string.IsNullOrEmpty(link.Url)) { var linkBuilder = new StringBuilder(); ExtractInlineText(link, theme, linkBuilder); - if (link.IsImage) - { + if (link.IsImage) { ProcessImageLink(linkBuilder.ToString(), link.Url, theme, builder); } - else - { + else { builder.AppendLink(link.Url, linkBuilder.ToString()); } } - else - { + else { ExtractInlineText(link, theme, builder); } } @@ -91,22 +81,19 @@ private static void ProcessLinkInline(LinkInline link, Theme theme, StringBuilde /// /// Processes image links with special styling. /// - private static void ProcessImageLink(string altText, string url, Theme theme, StringBuilder builder) - { + private static void ProcessImageLink(string altText, string url, Theme theme, StringBuilder builder) { // For now, render images as enhanced fallback since we can't easily make this async // In the future, this could be enhanced to support actual Sixel rendering // Check if the image format is likely supported bool isSupported = ImageFile.IsLikelySupportedImageFormat(url); - if (isSupported) - { + if (isSupported) { // Enhanced image representation for supported formats builder.Append("🖼️ "); builder.AppendLink(url, $"Image: {altText} (Sixel-ready)"); } - else - { + else { // Basic image representation for unsupported formats builder.Append("🖼️ "); builder.AppendLink(url, $"Image: {altText}"); @@ -116,8 +103,7 @@ private static void ProcessImageLink(string altText, string url, Theme theme, St /// /// Processes emphasis inline elements (bold, italic). /// - private static void ProcessEmphasisInline(EmphasisInline emph, Theme theme, StringBuilder builder) - { + private static void ProcessEmphasisInline(EmphasisInline emph, Theme theme, StringBuilder builder) { string[]? emphScopes = MarkdigTextMateScopeMapper.GetInlineScopes("Emphasis", emph.DelimiterCount); (int efg, int ebg, FontStyle efStyle) = TokenProcessor.ExtractThemeProperties(new MarkdownToken(emphScopes), theme); @@ -125,17 +111,15 @@ private static void ProcessEmphasisInline(EmphasisInline emph, Theme theme, Stri ExtractInlineText(emph, theme, emphBuilder); // Apply the theme colors/style to the emphasis text - if (efg != -1 || ebg != -1 || efStyle != TextMateSharp.Themes.FontStyle.NotSet) - { + if (efg != -1 || ebg != -1 || efStyle != FontStyle.NotSet) { Color emphColor = efg != -1 ? StyleHelper.GetColor(efg, theme) : Color.Default; Color emphBgColor = ebg != -1 ? StyleHelper.GetColor(ebg, theme) : Color.Default; Decoration emphDecoration = StyleHelper.GetDecoration(efStyle); - Style? emphStyle = new Style(emphColor, emphBgColor, emphDecoration); + var emphStyle = new Style(emphColor, emphBgColor, emphDecoration); builder.AppendWithStyle(emphStyle, emphBuilder.ToString()); } - else - { + else { builder.Append(emphBuilder); } } @@ -143,14 +127,12 @@ private static void ProcessEmphasisInline(EmphasisInline emph, Theme theme, Stri /// /// Processes inline code elements. /// - private static void ProcessCodeInline(CodeInline code, Theme theme, StringBuilder builder) - { + private static void ProcessCodeInline(CodeInline code, Theme theme, StringBuilder builder) { string[]? codeScopes = MarkdigTextMateScopeMapper.GetInlineScopes("CodeInline"); (int cfg, int cbg, FontStyle cfStyle) = TokenProcessor.ExtractThemeProperties(new MarkdownToken(codeScopes), theme); // Apply the theme colors/style to the inline code - if (cfg != -1 || cbg != -1 || cfStyle != TextMateSharp.Themes.FontStyle.NotSet) - { + if (cfg != -1 || cbg != -1 || cfStyle != FontStyle.NotSet) { Color codeColor = cfg != -1 ? StyleHelper.GetColor(cfg, theme) : Color.Default; Color codeBgColor = cbg != -1 ? StyleHelper.GetColor(cbg, theme) : Color.Default; Decoration codeDecoration = StyleHelper.GetDecoration(cfStyle); @@ -158,8 +140,7 @@ private static void ProcessCodeInline(CodeInline code, Theme theme, StringBuilde var codeStyle = new Style(codeColor, codeBgColor, codeDecoration); builder.AppendWithStyle(codeStyle, code.Content); } - else - { + else { builder.Append(code.Content.EscapeMarkup()); } } diff --git a/src/Core/Markdown/MarkdownRenderer.cs b/src/Core/Markdown/MarkdownRenderer.cs index caf3eb4..f94e86c 100644 --- a/src/Core/Markdown/MarkdownRenderer.cs +++ b/src/Core/Markdown/MarkdownRenderer.cs @@ -11,8 +11,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown; /// Markdown renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and avoids double-parsing overhead for better performance. /// -internal static class MarkdownRenderer -{ +internal static class MarkdownRenderer { /// /// Renders markdown content using Spectre.Console object building. /// This approach eliminates VT escaping issues and improves performance. @@ -21,36 +20,31 @@ internal static class MarkdownRenderer /// Theme object for styling /// Theme name for TextMateProcessor /// Rows object for Spectre.Console rendering - public static Rows Render(string markdown, Theme theme, ThemeName themeName) - { + public static Rows Render(string markdown, Theme theme, ThemeName themeName) { MarkdownPipeline? pipeline = CreateMarkdownPipeline(); Markdig.Syntax.MarkdownDocument? document = Markdig.Markdown.Parse(markdown, pipeline); var rows = new List(); bool lastWasContent = false; - for (int i = 0; i < document.Count; i++) - { + for (int i = 0; i < document.Count; i++) { Markdig.Syntax.Block? block = document[i]; // Use block renderer that builds Spectre.Console objects directly IRenderable? renderable = BlockRenderer.RenderBlock(block, theme, themeName); - if (renderable is not null) - { + if (renderable is not null) { // Add spacing before certain block types or when there was previous content bool needsSpacing = ShouldAddSpacing(block, lastWasContent); - if (needsSpacing && rows.Count > 0) - { + if (needsSpacing && rows.Count > 0) { rows.Add(Text.Empty); } rows.Add(renderable); lastWasContent = true; } - else - { + else { lastWasContent = false; } } @@ -62,8 +56,7 @@ public static Rows Render(string markdown, Theme theme, ThemeName themeName) /// Creates the Markdig pipeline with all necessary extensions enabled. /// /// Configured MarkdownPipeline - private static MarkdownPipeline CreateMarkdownPipeline() - { + private static MarkdownPipeline CreateMarkdownPipeline() { return new MarkdownPipelineBuilder() .UseAdvancedExtensions() .UsePipeTables() @@ -80,8 +73,7 @@ private static MarkdownPipeline CreateMarkdownPipeline() /// The current block being rendered /// Whether the previous element was content /// True if spacing should be added - private static bool ShouldAddSpacing(Markdig.Syntax.Block block, bool lastWasContent) - { + private static bool ShouldAddSpacing(Markdig.Syntax.Block block, bool lastWasContent) { return lastWasContent || block is Markdig.Syntax.HeadingBlock || block is Markdig.Syntax.FencedCodeBlock || diff --git a/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs b/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs index 55da49d..c3d2ded 100644 --- a/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs +++ b/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs @@ -7,34 +7,29 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Optimizations; /// Provides span-optimized operations for markdown validation and input processing. /// Reduces allocations during text analysis and validation operations. /// -internal static class SpanOptimizedMarkdownProcessor -{ +internal static class SpanOptimizedMarkdownProcessor { private static readonly SearchValues LineBreakChars = SearchValues.Create(['\r', '\n']); - private static readonly SearchValues WhitespaceChars = SearchValues.Create([' ', '\t', '\r', '\n']); + // private static readonly SearchValues WhitespaceChars = SearchValues.Create([' ', '\t', '\r', '\n']); /// /// Counts lines in markdown text using span operations for better performance. /// /// Markdown text to analyze /// Number of lines - public static int CountLinesOptimized(ReadOnlySpan markdown) - { + public static int CountLinesOptimized(ReadOnlySpan markdown) { if (markdown.IsEmpty) return 0; int lineCount = 1; // Start with 1 for the first line int index = 0; - while ((index = markdown[index..].IndexOfAny(LineBreakChars)) >= 0) - { + while ((index = markdown[index..].IndexOfAny(LineBreakChars)) >= 0) { // Handle CRLF as single line break if (index < markdown.Length - 1 && markdown[index] == '\r' && - markdown[index + 1] == '\n') - { + markdown[index + 1] == '\n') { index += 2; } - else - { + else { index++; } @@ -52,8 +47,7 @@ public static int CountLinesOptimized(ReadOnlySpan markdown) /// /// Markdown text to split /// Array of line strings - public static string[] SplitIntoLinesOptimized(ReadOnlySpan markdown) - { + public static string[] SplitIntoLinesOptimized(ReadOnlySpan markdown) { if (markdown.IsEmpty) return []; int lineCount = CountLinesOptimized(markdown); @@ -61,12 +55,10 @@ public static string[] SplitIntoLinesOptimized(ReadOnlySpan markdown) int lineIndex = 0; int start = 0; - for (int i = 0; i < markdown.Length; i++) - { + for (int i = 0; i < markdown.Length; i++) { bool isLineBreak = markdown[i] is '\r' or '\n'; - if (isLineBreak) - { + if (isLineBreak) { lines[lineIndex++] = markdown[start..i].ToString(); // Handle CRLF @@ -89,22 +81,18 @@ public static string[] SplitIntoLinesOptimized(ReadOnlySpan markdown) /// /// Markdown text to analyze /// Maximum line length - public static int FindMaxLineLengthOptimized(ReadOnlySpan markdown) - { + public static int FindMaxLineLengthOptimized(ReadOnlySpan markdown) { if (markdown.IsEmpty) return 0; int maxLength = 0; int currentLength = 0; - foreach (char c in markdown) - { - if (c is '\r' or '\n') - { + foreach (char c in markdown) { + if (c is '\r' or '\n') { maxLength = Math.Max(maxLength, currentLength); currentLength = 0; } - else - { + else { currentLength++; } } @@ -118,14 +106,11 @@ public static int FindMaxLineLengthOptimized(ReadOnlySpan markdown) /// /// Array of line strings /// Array of trimmed lines - public static string[] TrimLinesOptimized(string[] lines) - { + public static string[] TrimLinesOptimized(string[] lines) { string[]? trimmedLines = new string[lines.Length]; - for (int i = 0; i < lines.Length; i++) - { - if (string.IsNullOrEmpty(lines[i])) - { + for (int i = 0; i < lines.Length; i++) { + if (string.IsNullOrEmpty(lines[i])) { trimmedLines[i] = string.Empty; continue; } @@ -143,8 +128,7 @@ public static string[] TrimLinesOptimized(string[] lines) /// Lines to join /// Line ending to use (default: \n) /// Joined markdown text - public static string JoinLinesOptimized(ReadOnlySpan lines, ReadOnlySpan lineEnding = default) - { + public static string JoinLinesOptimized(ReadOnlySpan lines, ReadOnlySpan lineEnding = default) { if (lines.IsEmpty) return string.Empty; if (lines.Length == 1) return lines[0] ?? string.Empty; @@ -157,8 +141,7 @@ public static string JoinLinesOptimized(ReadOnlySpan lines, ReadOnlySpan var builder = new StringBuilder(totalLength); - for (int i = 0; i < lines.Length; i++) - { + for (int i = 0; i < lines.Length; i++) { if (i > 0) builder.Append(ending); if (lines[i] is not null) builder.Append(lines[i].AsSpan()); @@ -172,12 +155,10 @@ public static string JoinLinesOptimized(ReadOnlySpan lines, ReadOnlySpan /// /// Lines to filter /// Array with empty lines removed - public static string[] RemoveEmptyLinesOptimized(string[] lines) - { + public static string[] RemoveEmptyLinesOptimized(string[] lines) { // First pass: count non-empty lines int nonEmptyCount = 0; - foreach (string line in lines) - { + foreach (string line in lines) { if (!string.IsNullOrEmpty(line) && !line.AsSpan().Trim().IsEmpty) nonEmptyCount++; } @@ -189,8 +170,7 @@ public static string[] RemoveEmptyLinesOptimized(string[] lines) string[]? result = new string[nonEmptyCount]; int index = 0; - foreach (string line in lines) - { + foreach (string line in lines) { if (!string.IsNullOrEmpty(line) && !line.AsSpan().Trim().IsEmpty) result[index++] = line; } @@ -204,15 +184,13 @@ public static string[] RemoveEmptyLinesOptimized(string[] lines) /// Markdown text to analyze /// Character to count /// Number of occurrences - public static int CountCharacterOptimized(ReadOnlySpan markdown, char targetChar) - { + public static int CountCharacterOptimized(ReadOnlySpan markdown, char targetChar) { if (markdown.IsEmpty) return 0; int count = 0; int index = 0; - while ((index = markdown[index..].IndexOf(targetChar)) >= 0) - { + while ((index = markdown[index..].IndexOf(targetChar)) >= 0) { count++; index++; if (index >= markdown.Length) break; diff --git a/src/Core/Markdown/Renderers/BlockRenderer.cs b/src/Core/Markdown/Renderers/BlockRenderer.cs index bcdc99f..a44525f 100644 --- a/src/Core/Markdown/Renderers/BlockRenderer.cs +++ b/src/Core/Markdown/Renderers/BlockRenderer.cs @@ -10,8 +10,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// Block renderer that uses Spectre.Console object building instead of markup strings. /// This eliminates VT escaping issues and improves performance by avoiding double-parsing. /// -internal static class BlockRenderer -{ +internal static class BlockRenderer { /// /// Routes block elements to their appropriate renderers. /// All renderers build Spectre.Console objects directly instead of markup strings. @@ -20,10 +19,8 @@ internal static class BlockRenderer /// Theme for styling /// Theme name for TextMateProcessor /// Rendered block as a Spectre.Console object, or null if unsupported - public static IRenderable? RenderBlock(Block block, Theme theme, ThemeName themeName) - { - return block switch - { + public static IRenderable? RenderBlock(Block block, Theme theme, ThemeName themeName) { + return block switch { // Use renderers that build Spectre.Console objects directly HeadingBlock heading => HeadingRenderer.Render(heading, theme), ParagraphBlock paragraph => ParagraphRenderer.Render(paragraph, theme), diff --git a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs index 4b1410a..e9d6d5a 100644 --- a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs +++ b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs @@ -1,12 +1,12 @@ using System.Buffers; using System.Text; +using Markdig.Helpers; using Markdig.Syntax; +using PwshSpectreConsole.TextMate.Extensions; using Spectre.Console; using Spectre.Console.Rendering; -using PwshSpectreConsole.TextMate.Extensions; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using Markdig.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -14,8 +14,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// Code block renderer that builds Spectre.Console objects directly /// and fixes whitespace and detection issues. /// -internal static class CodeBlockRenderer -{ +internal static class CodeBlockRenderer { // Cached SearchValues for improved performance private static readonly SearchValues LanguageDelimiters = SearchValues.Create([' ', '\t', '{', '}', '(', ')', '[', ']']); @@ -26,18 +25,14 @@ internal static class CodeBlockRenderer /// Theme for styling /// Theme name for TextMateProcessor /// Rendered code block in a panel - public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Theme theme, ThemeName themeName) - { + public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Theme theme, ThemeName themeName) { string[] codeLines = ExtractCodeLinesWithWhitespaceHandling(fencedCode.Lines); string language = ExtractLanguageImproved(fencedCode.Info); - if (!string.IsNullOrEmpty(language)) - { - try - { + if (!string.IsNullOrEmpty(language)) { + try { Rows? rows = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); - if (rows is not null) - { + if (rows is not null) { // Convert internal Rows into Spectre.Console.Rows for Panel consumption var spectreRows = new Spectre.Console.Rows(rows.Renderables); return new Panel(spectreRows) @@ -45,8 +40,7 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them .Header(language, Justify.Left); } } - catch - { + catch { // Fallback to plain rendering } } @@ -54,13 +48,12 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them // Fallback: create Text object directly instead of markup strings return CreateOptimizedCodePanel(codeLines, language, theme); } /// - /// Renders an indented code block with proper whitespace handling. - /// - /// The code block to render - /// Theme for styling - /// Rendered code block in a panel - public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) - { + /// Renders an indented code block with proper whitespace handling. + /// + /// The code block to render + /// Theme for styling + /// Rendered code block in a panel + public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) { string[] codeLines = ExtractCodeLinesFromStringLineGroup(code.Lines); return CreateOptimizedCodePanel(codeLines, "code", theme); } @@ -68,17 +61,14 @@ public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) /// /// Extracts code lines with simple and safe processing to avoid bounds issues. /// - private static string[] ExtractCodeLinesWithWhitespaceHandling(Markdig.Helpers.StringLineGroup lines) - { + private static string[] ExtractCodeLinesWithWhitespaceHandling(StringLineGroup lines) { if (lines.Count == 0) return []; var codeLines = new List(lines.Count); - foreach (StringLine line in lines.Lines) - { - try - { + foreach (StringLine line in lines.Lines) { + try { // Use the safest approach: let the slice handle its own bounds string lineText = line.Slice.ToString(); @@ -87,20 +77,20 @@ private static string[] ExtractCodeLinesWithWhitespaceHandling(Markdig.Helpers.S codeLines.Add(lineText); } - catch - { + catch { // If any error occurs, just use empty line codeLines.Add(string.Empty); } } // Convert to array and remove trailing empty lines - return RemoveTrailingEmptyLines(codeLines.ToArray()); - } /// + return RemoveTrailingEmptyLines([.. codeLines]); + } + + /// /// Extracts code lines from a string line group (for indented code blocks). /// - private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.StringLineGroup lines) - { + private static string[] ExtractCodeLinesFromStringLineGroup(StringLineGroup lines) { if (lines.Count == 0) return []; @@ -112,8 +102,7 @@ private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.Stri string[] splitLines = content.Split(['\r', '\n'], StringSplitOptions.None); // Process each line to handle whitespace correctly - for (int i = 0; i < splitLines.Length; i++) - { + for (int i = 0; i < splitLines.Length; i++) { splitLines[i] = TrimTrailingWhitespace(splitLines[i].AsSpan()).ToString(); } @@ -123,8 +112,7 @@ private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.Stri /// /// Improved language extraction with better detection patterns. /// - private static string ExtractLanguageImproved(string? info) - { + private static string ExtractLanguageImproved(string? info) { if (string.IsNullOrWhiteSpace(info)) return string.Empty; @@ -135,8 +123,7 @@ private static string ExtractLanguageImproved(string? info) // Find first whitespace or special character to extract just the language int endIndex = infoSpan.IndexOfAny(LanguageDelimiters); - if (endIndex >= 0) - { + if (endIndex >= 0) { infoSpan = infoSpan[..endIndex]; } @@ -149,10 +136,8 @@ private static string ExtractLanguageImproved(string? info) /// /// Normalizes language names to improve code block detection. /// - private static string NormalizeLanguageName(string language) - { - return language switch - { + private static string NormalizeLanguageName(string language) { + return language switch { "c#" or "csharp" or "cs" => "csharp", "js" or "javascript" => "javascript", "ts" or "typescript" => "typescript", @@ -174,11 +159,9 @@ private static string NormalizeLanguageName(string language) /// /// Trims only trailing whitespace while preserving leading whitespace for indentation. /// - private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line) - { + private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line) { int end = line.Length; - while (end > 0 && char.IsWhiteSpace(line[end - 1])) - { + while (end > 0 && char.IsWhiteSpace(line[end - 1])) { end--; } return line[..end]; @@ -187,16 +170,14 @@ private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line /// /// Removes trailing empty lines that cause unnecessary whitespace in code blocks. /// - private static string[] RemoveTrailingEmptyLines(string[] lines) - { + private static string[] RemoveTrailingEmptyLines(string[] lines) { if (lines.Length == 0) return lines; int lastNonEmptyIndex = lines.Length - 1; // Find the last non-empty line - while (lastNonEmptyIndex >= 0 && string.IsNullOrWhiteSpace(lines[lastNonEmptyIndex])) - { + while (lastNonEmptyIndex >= 0 && string.IsNullOrWhiteSpace(lines[lastNonEmptyIndex])) { lastNonEmptyIndex--; } @@ -217,10 +198,9 @@ private static string[] RemoveTrailingEmptyLines(string[] lines) /// Creates an optimized code panel using Text objects instead of markup strings. /// This eliminates VT escaping issues and improves performance. /// - private static Panel CreateOptimizedCodePanel(string[] codeLines, string language, Theme theme) - { + private static Panel CreateOptimizedCodePanel(string[] codeLines, string language, Theme theme) { // Get theme colors for code blocks - string[] codeScopes = new[] { "text.html.markdown", "markup.fenced_code.block.markdown" }; + string[] codeScopes = ["text.html.markdown", "markup.fenced_code.block.markdown"]; (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( new MarkdownToken(codeScopes), theme); diff --git a/src/Core/Markdown/Renderers/HeadingRenderer.cs b/src/Core/Markdown/Renderers/HeadingRenderer.cs index 63eeb33..ec977fb 100644 --- a/src/Core/Markdown/Renderers/HeadingRenderer.cs +++ b/src/Core/Markdown/Renderers/HeadingRenderer.cs @@ -10,8 +10,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// Heading renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and avoids double-parsing overhead. /// -internal static class HeadingRenderer -{ +internal static class HeadingRenderer { /// /// Renders a heading block by building Spectre.Console Text objects directly. /// This approach eliminates VT escaping issues and improves performance. @@ -19,8 +18,7 @@ internal static class HeadingRenderer /// The heading block to render /// Theme for styling /// Rendered heading as a Text object with proper styling - public static IRenderable Render(HeadingBlock heading, Theme theme) - { + public static IRenderable Render(HeadingBlock heading, Theme theme) { // Extract heading text without building markup strings string headingText = ExtractHeadingText(heading); @@ -38,32 +36,29 @@ public static IRenderable Render(HeadingBlock heading, Theme theme) /// /// Extracts plain text from heading inline elements without building markup. /// - private static string ExtractHeadingText(HeadingBlock heading) - { + private static string ExtractHeadingText(HeadingBlock heading) { if (heading.Inline is null) return ""; var textBuilder = new System.Text.StringBuilder(); - foreach (Inline inline in heading.Inline) - { - switch (inline) - { - case Markdig.Syntax.Inlines.LiteralInline literal: + foreach (Inline inline in heading.Inline) { + switch (inline) { + case LiteralInline literal: textBuilder.Append(literal.Content.ToString()); break; - case Markdig.Syntax.Inlines.EmphasisInline emphasis: + case EmphasisInline emphasis: // For headings, we'll just extract the text without emphasis styling // since the heading style takes precedence ExtractInlineTextRecursive(emphasis, textBuilder); break; - case Markdig.Syntax.Inlines.CodeInline code: + case CodeInline code: textBuilder.Append(code.Content); break; - case Markdig.Syntax.Inlines.LinkInline link: + case LinkInline link: // Extract link text, not the URL ExtractInlineTextRecursive(link, textBuilder); break; @@ -80,47 +75,42 @@ private static string ExtractHeadingText(HeadingBlock heading) /// /// Recursively extracts text from inline elements. /// - private static void ExtractInlineTextRecursive(Markdig.Syntax.Inlines.Inline inline, System.Text.StringBuilder builder) - { - switch (inline) - { - case Markdig.Syntax.Inlines.LiteralInline literal: + private static void ExtractInlineTextRecursive(Inline inline, System.Text.StringBuilder builder) { + switch (inline) { + case LiteralInline literal: builder.Append(literal.Content.ToString()); break; - case Markdig.Syntax.Inlines.ContainerInline container: - foreach (Inline child in container) - { + case ContainerInline container: + foreach (Inline child in container) { ExtractInlineTextRecursive(child, builder); } break; - case Markdig.Syntax.Inlines.LeafInline leaf: - if (leaf is Markdig.Syntax.Inlines.CodeInline code) - { + case LeafInline leaf: + if (leaf is CodeInline code) { builder.Append(code.Content); } break; + default: + break; } } /// /// Creates appropriate styling for headings based on theme and level. /// - private static Style CreateHeadingStyle(int foreground, int background, TextMateSharp.Themes.FontStyle fontStyle, Theme theme, int level) - { + private static Style CreateHeadingStyle(int foreground, int background, FontStyle fontStyle, Theme theme, int level) { Color? foregroundColor = null; Color? backgroundColor = null; Decoration decoration = Decoration.None; // Apply theme colors if available - if (foreground != -1) - { + if (foreground != -1) { foregroundColor = StyleHelper.GetColor(foreground, theme); } - if (background != -1) - { + if (background != -1) { backgroundColor = StyleHelper.GetColor(background, theme); } @@ -128,14 +118,10 @@ private static Style CreateHeadingStyle(int foreground, int background, TextMate decoration = StyleHelper.GetDecoration(fontStyle); // Apply level-specific styling as fallbacks - if (foregroundColor is null) - { - foregroundColor = GetDefaultHeadingColor(level); - } + foregroundColor ??= GetDefaultHeadingColor(level); // Ensure headings are bold by default - if (decoration == Decoration.None) - { + if (decoration == Decoration.None) { decoration = Decoration.Bold; } @@ -145,10 +131,8 @@ private static Style CreateHeadingStyle(int foreground, int background, TextMate /// /// Gets default colors for heading levels when theme doesn't provide them. /// - private static Color GetDefaultHeadingColor(int level) - { - return level switch - { + private static Color GetDefaultHeadingColor(int level) { + return level switch { 1 => Color.Red, 2 => Color.Orange1, 3 => Color.Yellow, diff --git a/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs b/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs index 5a52a33..0409130 100644 --- a/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs +++ b/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs @@ -6,14 +6,10 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// /// Renders markdown horizontal rules (thematic breaks). /// -internal static class HorizontalRuleRenderer -{ +internal static class HorizontalRuleRenderer { /// /// Renders a horizontal rule as a styled line. /// /// Rendered horizontal rule - public static IRenderable Render() - { - return new Rule().RuleStyle(Style.Parse("grey")); - } + public static IRenderable Render() => new Rule().RuleStyle(Style.Parse("grey")); } diff --git a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs index b8b7ae1..a98d8e2 100644 --- a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs +++ b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs @@ -9,8 +9,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// /// Renders HTML blocks with syntax highlighting. /// -internal static class HtmlBlockRenderer -{ +internal static class HtmlBlockRenderer { /// /// Renders an HTML block with syntax highlighting when possible. /// @@ -18,24 +17,20 @@ internal static class HtmlBlockRenderer /// Theme for styling /// Theme name for TextMateProcessor /// Rendered HTML block in a panel - public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName themeName) - { + public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName themeName) { List htmlLines = ExtractHtmlLines(htmlBlock); // Try to render with HTML syntax highlighting - try - { + try { Rows? htmlRows = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); - if (htmlRows is not null) - { + if (htmlRows is not null) { var spectreRows = new Spectre.Console.Rows(htmlRows.Renderables); return new Panel(spectreRows) .Border(BoxBorder.Rounded) .Header("html", Justify.Left); } } - catch - { + catch { // Fallback to plain rendering } @@ -46,12 +41,10 @@ public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName the /// /// Extracts HTML lines from the HTML block. /// - private static List ExtractHtmlLines(HtmlBlock htmlBlock) - { + private static List ExtractHtmlLines(HtmlBlock htmlBlock) { var htmlLines = new List(); - for (int i = 0; i < htmlBlock.Lines.Count; i++) - { + for (int i = 0; i < htmlBlock.Lines.Count; i++) { Markdig.Helpers.StringLine line = htmlBlock.Lines.Lines[i]; htmlLines.Add(line.Slice.ToString()); } @@ -62,8 +55,7 @@ private static List ExtractHtmlLines(HtmlBlock htmlBlock) /// /// Creates a fallback HTML panel when syntax highlighting fails. /// - private static Panel CreateFallbackHtmlPanel(List htmlLines) - { + private static Panel CreateFallbackHtmlPanel(List htmlLines) { string? htmlText = Markup.Escape(string.Join("\n", htmlLines)); return new Panel(new Markup(htmlText)) diff --git a/src/Core/Markdown/Renderers/ImageRenderer.cs b/src/Core/Markdown/Renderers/ImageRenderer.cs index 282c146..787f18c 100644 --- a/src/Core/Markdown/Renderers/ImageRenderer.cs +++ b/src/Core/Markdown/Renderers/ImageRenderer.cs @@ -10,8 +10,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// /// Handles rendering of images in markdown using Sixel format when possible. /// -internal static class ImageRenderer -{ +internal static class ImageRenderer { private static string? _lastSixelError; private static string? _lastImageError; private static readonly TimeSpan ImageTimeout = TimeSpan.FromSeconds(5); // Increased to 5 seconds @@ -24,17 +23,14 @@ internal static class ImageRenderer /// Maximum width for the image (optional) /// Maximum height for the image (optional) /// A renderable representing the image or fallback - public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) - { - try - { + public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { + try { // Clear previous errors _lastImageError = null; _lastSixelError = null; // Check if the image format is likely supported - if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) - { + if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) { _lastImageError = $"Unsupported image format: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } @@ -43,33 +39,28 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW string? localImagePath = null; Task imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); - if (imageTask.Wait(ImageTimeout)) - { + if (imageTask.Wait(ImageTimeout)) { localImagePath = imageTask.Result; } - else - { + else { // Timeout occurred _lastImageError = $"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } - if (localImagePath is null) - { + if (localImagePath is null) { _lastImageError = $"Failed to normalize image source: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } // Verify the downloaded file exists and has content - if (!File.Exists(localImagePath)) - { + if (!File.Exists(localImagePath)) { _lastImageError = $"Downloaded image file does not exist: {localImagePath}"; return CreateImageFallback(altText, imageUrl); } var fileInfo = new FileInfo(localImagePath); - if (fileInfo.Length == 0) - { + if (fileInfo.Length == 0) { _lastImageError = $"Downloaded image file is empty: {localImagePath} (0 bytes)"; return CreateImageFallback(altText, imageUrl); } @@ -78,19 +69,16 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW int defaultMaxWidth = maxWidth ?? 80; // Default to ~80 characters wide for terminal display int defaultMaxHeight = maxHeight ?? 30; // Default to ~30 lines high - if (TryCreateSixelImage(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) - { + if (TryCreateSixelImage(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { return sixelImage; } - else - { + else { // Fallback to enhanced link representation with file info _lastImageError = $"SixelImage creation failed. File: {localImagePath} ({fileInfo.Length} bytes). Sixel error: {_lastSixelError}"; return CreateEnhancedImageFallback(altText, imageUrl, localImagePath); } } - catch (Exception ex) - { + catch (Exception ex) { // If anything goes wrong, fall back to the basic link representation _lastImageError = $"Exception in RenderImage: {ex.Message}"; return CreateImageFallback(altText, imageUrl); @@ -105,13 +93,10 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW /// Maximum width for the image (optional) /// Maximum height for the image (optional) /// A renderable representing the image or fallback - public static IRenderable RenderImageInline(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) - { - try - { + public static IRenderable RenderImageInline(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { + try { // Check if the image format is likely supported - if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) - { + if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) { return CreateImageFallbackInline(altText, imageUrl); } @@ -119,18 +104,15 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int string? localImagePath = null; Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); - if (imageTask.Wait(ImageTimeout)) - { + if (imageTask.Wait(ImageTimeout)) { localImagePath = imageTask.Result; } - else - { + else { // Timeout occurred return CreateImageFallbackInline(altText, imageUrl); } - if (localImagePath is null) - { + if (localImagePath is null) { return CreateImageFallbackInline(altText, imageUrl); } @@ -138,18 +120,15 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int int width = maxWidth ?? 60; // Default max width for inline images int height = maxHeight ?? 20; // Default max height for inline images - if (TryCreateSixelImage(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) - { + if (TryCreateSixelImage(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { return sixelImage; } - else - { + else { // Fallback to inline link representation return CreateImageFallbackInline(altText, imageUrl); } } - catch - { + catch { // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } @@ -163,12 +142,10 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int /// Maximum height /// The created SixelImage, if successful /// True if SixelImage was successfully created - private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) - { + private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { result = null; - try - { + try { // Try multiple approaches to find SixelImage Type? sixelImageType = null; @@ -178,37 +155,29 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); // If that fails, search through loaded assemblies - if (sixelImageType is null) - { - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { + if (sixelImageType is null) { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { string? assemblyName = assembly.GetName().Name; - if (assemblyName?.Contains("Spectre.Console") == true) - { + if (assemblyName?.Contains("Spectre.Console") == true) { // SixelImage is in Spectre.Console namespace regardless of assembly sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); - if (sixelImageType is not null) - { + if (sixelImageType is not null) { break; } } } } - if (sixelImageType is null) - { + if (sixelImageType is null) { // Debug: Let's see what Spectre.Console types are available - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - if (assembly.GetName().Name?.Contains("Spectre.Console") == true) - { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { + if (assembly.GetName().Name?.Contains("Spectre.Console") == true) { string?[]? spectreTypes = [.. assembly.GetTypes() .Where(t => t.Name.Contains("Sixel", StringComparison.OrdinalIgnoreCase)) .Select(t => t.FullName) .Where(name => name is not null)]; - if (spectreTypes.Length > 0) - { + if (spectreTypes.Length > 0) { // Found some Sixel-related types, try the first one sixelImageType = assembly.GetType(spectreTypes[0]!); break; @@ -217,69 +186,56 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma } } - if (sixelImageType is null) - { + if (sixelImageType is null) { return false; } // Create SixelImage instance ConstructorInfo? constructor = sixelImageType.GetConstructor([typeof(string), typeof(bool)]); - if (constructor is null) - { + if (constructor is null) { return false; } object? sixelInstance = constructor.Invoke([imagePath, false]); // false = animation enabled - if (sixelInstance is null) - { + if (sixelInstance is null) { return false; } // Apply size constraints if available - if (maxWidth.HasValue) - { + if (maxWidth.HasValue) { PropertyInfo? maxWidthProperty = sixelImageType.GetProperty("MaxWidth"); - if (maxWidthProperty is not null && maxWidthProperty.CanWrite) - { + if (maxWidthProperty is not null && maxWidthProperty.CanWrite) { maxWidthProperty.SetValue(sixelInstance, maxWidth.Value); } - else - { + else { // Try method-based approach as fallback MethodInfo? maxWidthMethod = sixelImageType.GetMethod("MaxWidth"); - if (maxWidthMethod is not null) - { + if (maxWidthMethod is not null) { sixelInstance = maxWidthMethod.Invoke(sixelInstance, [maxWidth.Value]); } } } - if (maxHeight.HasValue) - { + if (maxHeight.HasValue) { PropertyInfo? maxHeightProperty = sixelImageType.GetProperty("MaxHeight"); - if (maxHeightProperty?.CanWrite == true) - { + if (maxHeightProperty?.CanWrite == true) { maxHeightProperty.SetValue(sixelInstance, maxHeight.Value); } - else - { + else { // Try method-based approach as fallback MethodInfo? maxHeightMethod = sixelImageType.GetMethod("MaxHeight"); - if (maxHeightMethod is not null) - { + if (maxHeightMethod is not null) { sixelInstance = maxHeightMethod.Invoke(sixelInstance, [maxHeight.Value]); } } } - if (sixelInstance is IRenderable renderable) - { + if (sixelInstance is IRenderable renderable) { result = renderable; return true; } } - catch (Exception ex) - { + catch (Exception ex) { // Capture the error for debugging _lastSixelError = ex.Message; } @@ -293,8 +249,7 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma /// Alternative text for the image /// URL or path to the image /// A markup string representing the image as a link - private static Markup CreateImageFallback(string altText, string imageUrl) - { + private static Markup CreateImageFallback(string altText, string imageUrl) { string? linkText = $"🖼️ Image: {altText.EscapeMarkup()}"; string? linkMarkup = $"[blue link={imageUrl.EscapeMarkup()}]{linkText}[/]"; return new Markup(linkMarkup); @@ -307,11 +262,9 @@ private static Markup CreateImageFallback(string altText, string imageUrl) /// Original URL or path to the image /// Local path to the image file /// A panel with enhanced image information - private static IRenderable CreateEnhancedImageFallback(string altText, string imageUrl, string localPath) - { - try - { - var fileInfo = new System.IO.FileInfo(localPath); + private static IRenderable CreateEnhancedImageFallback(string altText, string imageUrl, string localPath) { + try { + var fileInfo = new FileInfo(localPath); string? sizeText = fileInfo.Exists ? $" ({fileInfo.Length / 1024:N0} KB)" : ""; var content = new Markup($"🖼️ [blue link={imageUrl.EscapeMarkup()}]{altText.EscapeMarkup()}[/]{sizeText}"); @@ -321,8 +274,7 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im .Border(BoxBorder.Rounded) .BorderColor(Color.Grey); } - catch - { + catch { return CreateImageFallback(altText, imageUrl); } } @@ -333,8 +285,7 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im /// Alternative text for the image /// URL or path to the image /// A markup string representing the image as a link - private static Markup CreateImageFallbackInline(string altText, string imageUrl) - { + private static Markup CreateImageFallbackInline(string altText, string imageUrl) { string? linkText = $"🖼️ {altText.EscapeMarkup()}"; string? linkMarkup = $"[blue link={imageUrl.EscapeMarkup()}]{linkText}[/]"; return new Markup(linkMarkup); @@ -349,10 +300,7 @@ private static Markup CreateImageFallbackInline(string altText, string imageUrl) /// Maximum height for the image (optional) /// A renderable representing the image or fallback [Obsolete("Use RenderImage instead")] - public static Task RenderImageAsync(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) - { - return Task.FromResult(RenderImage(altText, imageUrl, maxWidth, maxHeight)); - } + public static Task RenderImageAsync(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) => Task.FromResult(RenderImage(altText, imageUrl, maxWidth, maxHeight)); /// /// Legacy async method for backward compatibility. Calls the synchronous RenderImageInline method. @@ -363,37 +311,26 @@ public static Task RenderImageAsync(string altText, string imageUrl /// Maximum height for the image (optional) /// A renderable representing the image or fallback [Obsolete("Use RenderImageInline instead")] - public static Task RenderImageInlineAsync(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) - { - return Task.FromResult(RenderImageInline(altText, imageUrl, maxWidth, maxHeight)); - } + public static Task RenderImageInlineAsync(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) => Task.FromResult(RenderImageInline(altText, imageUrl, maxWidth, maxHeight)); /// /// Gets debug information about the last image processing error. /// /// The last error message, if any - public static string? GetLastImageError() - { - return _lastImageError; - } + public static string? GetLastImageError() => _lastImageError; /// /// Gets debug information about the last Sixel error. /// /// The last error message, if any - public static string? GetLastSixelError() - { - return _lastSixelError; - } + public static string? GetLastSixelError() => _lastSixelError; /// /// Checks if SixelImage type is available in the current environment. /// /// True if SixelImage can be found - public static bool IsSixelImageAvailable() - { - try - { + public static bool IsSixelImageAvailable() { + try { Type? sixelImageType = null; // Try direct approaches first @@ -404,11 +341,9 @@ public static bool IsSixelImageAvailable() return true; // Search through loaded assemblies - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { string? assemblyName = assembly.GetName().Name; - if (assemblyName?.Contains("Spectre.Console") == true) - { + if (assemblyName?.Contains("Spectre.Console") == true) { sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); if (sixelImageType is not null) return true; @@ -417,8 +352,7 @@ public static bool IsSixelImageAvailable() return false; } - catch - { + catch { return false; } } diff --git a/src/Core/Markdown/Renderers/ListRenderer.cs b/src/Core/Markdown/Renderers/ListRenderer.cs index f770481..4e028f1 100644 --- a/src/Core/Markdown/Renderers/ListRenderer.cs +++ b/src/Core/Markdown/Renderers/ListRenderer.cs @@ -12,8 +12,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// List renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and avoids double-parsing overhead. /// -internal static class ListRenderer -{ +internal static class ListRenderer { private const string TaskCheckedEmoji = "✅ "; private const string TaskUncheckedEmoji = "⬜ "; // More visible white square private const string UnorderedBullet = "• "; @@ -25,14 +24,12 @@ internal static class ListRenderer /// The list block to render /// Theme for styling /// Rendered list as a Paragraph with proper styling - public static IRenderable Render(ListBlock list, Theme theme) - { + public static IRenderable Render(ListBlock list, Theme theme) { var paragraph = new Paragraph(); int number = 1; bool isFirstItem = true; - foreach (ListItemBlock item in list.Cast()) - { + foreach (ListItemBlock item in list.Cast()) { // Add line break between items (except for the first) if (!isFirstItem) paragraph.Append("\n", Style.Plain); @@ -56,14 +53,10 @@ public static IRenderable Render(ListBlock list, Theme theme) /// /// Detects if a list item is a task list item using Markdig's native TaskList support. /// - private static (bool isTaskList, bool isChecked) DetectTaskListItem(ListItemBlock item) - { - if (item.FirstOrDefault() is ParagraphBlock paragraph && paragraph.Inline is not null) - { - foreach (Inline inline in paragraph.Inline) - { - if (inline is TaskList taskList) - { + private static (bool isTaskList, bool isChecked) DetectTaskListItem(ListItemBlock item) { + if (item.FirstOrDefault() is ParagraphBlock paragraph && paragraph.Inline is not null) { + foreach (Inline inline in paragraph.Inline) { + if (inline is TaskList taskList) { return (true, taskList.Checked); } } @@ -75,39 +68,23 @@ private static (bool isTaskList, bool isChecked) DetectTaskListItem(ListItemBloc /// /// Creates the appropriate prefix text for list items. /// - private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) - { - if (isTaskList) - { - return isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji; - } - else if (isOrdered) - { - return $"{number++}. "; - } - else - { - return UnorderedBullet; - } + private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) { + return isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; } /// /// Creates the appropriate prefix for list items as styled Text objects. /// - private static Text CreateListPrefix(bool isOrdered, bool isTaskList, bool isChecked, ref int number) - { - if (isTaskList) - { + private static Text CreateListPrefix(bool isOrdered, bool isTaskList, bool isChecked, ref int number) { + if (isTaskList) { string emoji = isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji; return new Text(emoji, Style.Plain); } - else if (isOrdered) - { + else if (isOrdered) { string numberText = $"{number++}. "; return new Text(numberText, Style.Plain); } - else - { + else { return new Text(UnorderedBullet, Style.Plain); } } @@ -116,12 +93,9 @@ private static Text CreateListPrefix(bool isOrdered, bool isTaskList, bool isChe /// Appends list item content directly to the paragraph using styled Text objects. /// This eliminates the need for markup parsing and VT escaping. /// - private static void AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme) - { - foreach (Block subBlock in item) - { - switch (subBlock) - { + private static void AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme) { + foreach (Block subBlock in item) { + switch (subBlock) { case ParagraphBlock subPara: AppendInlineContent(paragraph, subPara.Inline, theme); break; @@ -134,14 +108,15 @@ private static void AppendListItemContent(Paragraph paragraph, ListItemBlock ite case ListBlock nestedList: // For nested lists, render as indented text content string nestedContent = RenderNestedListAsText(nestedList, theme, 1); - if (!string.IsNullOrEmpty(nestedContent)) - { + if (!string.IsNullOrEmpty(nestedContent)) { // Show nested content immediately under the parent without pre-padding paragraph.Append(nestedContent, Style.Plain); // Then add a blank line after the nested block to visually separate from following siblings // paragraph.Append("\n", Style.Plain); } break; + default: + break; } } } @@ -150,17 +125,17 @@ private static void AppendListItemContent(Paragraph paragraph, ListItemBlock ite /// Processes inline content and appends it directly to the paragraph with proper styling. /// This method builds Text objects directly instead of markup strings. /// - private static void AppendInlineContent(Paragraph paragraph, Markdig.Syntax.Inlines.ContainerInline? inlines, Theme theme) - { + private static void AppendInlineContent(Paragraph paragraph, ContainerInline? inlines, Theme theme) { if (inlines is null) return; // Use the same advanced processing as ParagraphRenderer ParagraphRenderer.ProcessInlineElements(paragraph, inlines, theme); - } /// + } + + /// /// Extracts plain text from inline elements without markup. /// - private static string ExtractInlineText(Inline inline) - { + private static string ExtractInlineText(Inline inline) { var builder = new StringBuilder(); ExtractInlineTextRecursive(inline, builder); return builder.ToString(); @@ -169,43 +144,39 @@ private static string ExtractInlineText(Inline inline) /// /// Recursively extracts text from inline elements. /// - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) - { - switch (inline) - { - case Markdig.Syntax.Inlines.LiteralInline literal: + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { + switch (inline) { + case LiteralInline literal: builder.Append(literal.Content.ToString()); break; - case Markdig.Syntax.Inlines.ContainerInline container: - foreach (Inline child in container) - { + case ContainerInline container: + foreach (Inline child in container) { ExtractInlineTextRecursive(child, builder); } break; - case Markdig.Syntax.Inlines.LeafInline leaf: + case LeafInline leaf: // For leaf inlines like CodeInline, extract their content - if (leaf is Markdig.Syntax.Inlines.CodeInline code) - { + if (leaf is CodeInline code) { builder.Append(code.Content); } break; + default: + break; } } /// /// Renders nested lists as indented text content. /// - private static string RenderNestedListAsText(ListBlock list, Theme theme, int indentLevel) - { + private static string RenderNestedListAsText(ListBlock list, Theme theme, int indentLevel) { var builder = new StringBuilder(); - string indent = new string(' ', indentLevel * 2); + string indent = new(' ', indentLevel * 2); int number = 1; bool isFirstItem = true; - foreach (ListItemBlock item in list) - { + foreach (ListItemBlock item in list.Cast()) { if (!isFirstItem) builder.Append('\n'); @@ -213,16 +184,13 @@ private static string RenderNestedListAsText(ListBlock list, Theme theme, int in (bool isTaskList, bool isChecked) = DetectTaskListItem(item); - if (isTaskList) - { + if (isTaskList) { builder.Append(isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji); } - else if (list.IsOrdered) - { + else if (list.IsOrdered) { builder.Append(System.Globalization.CultureInfo.InvariantCulture, $"{number++}. "); } - else - { + else { builder.Append(UnorderedBullet); } @@ -239,24 +207,19 @@ private static string RenderNestedListAsText(ListBlock list, Theme theme, int in /// /// Simple text extraction for nested list items. /// - private static string ExtractListItemTextSimple(ListItemBlock item) - { + private static string ExtractListItemTextSimple(ListItemBlock item) { var builder = new StringBuilder(); - foreach (Block subBlock in item) - { - if (subBlock is ParagraphBlock subPara && subPara.Inline is not null) - { - foreach (Inline inline in subPara.Inline) - { + foreach (Block subBlock in item) { + if (subBlock is ParagraphBlock subPara && subPara.Inline is not null) { + foreach (Inline inline in subPara.Inline) { if (inline is not TaskList) // Skip TaskList markers { builder.Append(ExtractInlineText(inline)); } } } - else if (subBlock is CodeBlock subCode) - { + else if (subBlock is CodeBlock subCode) { builder.Append(subCode.Lines.ToString()); } } diff --git a/src/Core/Markdown/Renderers/ParagraphRenderer.cs b/src/Core/Markdown/Renderers/ParagraphRenderer.cs index 184cde8..f8e6039 100644 --- a/src/Core/Markdown/Renderers/ParagraphRenderer.cs +++ b/src/Core/Markdown/Renderers/ParagraphRenderer.cs @@ -1,13 +1,13 @@ -using System.Text.RegularExpressions; +using System.Text; +using System.Text.RegularExpressions; +using Markdig.Extensions; using Markdig.Extensions.AutoLinks; +using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using Markdig.Extensions; -using Markdig.Extensions.TaskLists; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using System.Text; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -15,8 +15,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// Paragraph renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and avoids double-parsing overhead. /// -internal static partial class ParagraphRenderer -{ +internal static partial class ParagraphRenderer { // reuse static arrays for common scope queries to avoid allocating new arrays per call private static readonly string[] LinkScope = ["markup.underline.link"]; @@ -27,12 +26,10 @@ internal static partial class ParagraphRenderer /// The paragraph block to render /// Theme for styling /// Rendered paragraph as a Paragraph object with proper inline styling - public static IRenderable Render(ParagraphBlock paragraph, Theme theme) - { + public static IRenderable Render(ParagraphBlock paragraph, Theme theme) { var spectreConsole = new Paragraph(); - if (paragraph.Inline is not null) - { + if (paragraph.Inline is not null) { ProcessInlineElements(spectreConsole, paragraph.Inline, theme); } @@ -42,22 +39,16 @@ public static IRenderable Render(ParagraphBlock paragraph, Theme theme) /// /// Processes inline elements and adds them directly to the Paragraph with appropriate styling. /// - internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline inlines, Theme theme) - { - foreach (Inline inline in inlines) - { - switch (inline) - { + internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline inlines, Theme theme) { + foreach (Inline inline in inlines) { + switch (inline) { case LiteralInline literal: string literalText = literal.Content.ToString(); // Check for username patterns like @username - if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) - { - foreach (TextSegment segment in segments) - { - if (segment.IsUsername) - { + if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) { + foreach (TextSegment segment in segments) { + if (segment.IsUsername) { // Create clickable username link (you could customize the URL pattern) var usernameStyle = new Style( foreground: Color.Blue, @@ -66,14 +57,12 @@ internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline ); paragraph.Append(segment.Text, usernameStyle); } - else - { + else { paragraph.Append(segment.Text, Style.Plain); } } } - else - { + else { paragraph.Append(literalText, Style.Plain); } break; @@ -120,11 +109,9 @@ internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline /// /// Processes emphasis (bold/italic) inline elements while preserving nested links. /// - private static void ProcessEmphasisInline(Paragraph paragraph, EmphasisInline emphasis, Theme theme) - { + private static void ProcessEmphasisInline(Paragraph paragraph, EmphasisInline emphasis, Theme theme) { // Determine emphasis style based on delimiter count - Decoration decoration = emphasis.DelimiterCount switch - { + Decoration decoration = emphasis.DelimiterCount switch { 1 => Decoration.Italic, // Single * or _ 2 => Decoration.Bold, // Double ** or __ 3 => Decoration.Bold | Decoration.Italic, // Triple *** or ___ @@ -139,23 +126,17 @@ private static void ProcessEmphasisInline(Paragraph paragraph, EmphasisInline em /// Processes inline elements while applying a decoration (like bold/italic) to text elements, /// but preserving special handling for links and other complex inlines. /// - private static void ProcessInlineElementsWithDecoration(Paragraph paragraph, ContainerInline container, Decoration decoration, Theme theme) - { - foreach (Inline inline in container) - { - switch (inline) - { + private static void ProcessInlineElementsWithDecoration(Paragraph paragraph, ContainerInline container, Decoration decoration, Theme theme) { + foreach (Inline inline in container) { + switch (inline) { case LiteralInline literal: string literalText = literal.Content.ToString(); var emphasisStyle = new Style(decoration: decoration); // Check for username patterns like @username - if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) - { - foreach (TextSegment segment in segments) - { - if (segment.IsUsername) - { + if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) { + foreach (TextSegment segment in segments) { + if (segment.IsUsername) { // Create clickable username link with emphasis var usernameStyle = new Style( foreground: Color.Blue, @@ -164,14 +145,12 @@ private static void ProcessInlineElementsWithDecoration(Paragraph paragraph, Con ); paragraph.Append(segment.Text, usernameStyle); } - else - { + else { paragraph.Append(segment.Text, emphasisStyle); } } } - else - { + else { paragraph.Append(literalText, emphasisStyle); } break; @@ -188,8 +167,7 @@ private static void ProcessInlineElementsWithDecoration(Paragraph paragraph, Con case EmphasisInline nestedEmphasis: // Handle nested emphasis by combining decorations - Decoration nestedDecoration = nestedEmphasis.DelimiterCount switch - { + Decoration nestedDecoration = nestedEmphasis.DelimiterCount switch { 1 => Decoration.Italic, 2 => Decoration.Bold, 3 => Decoration.Bold | Decoration.Italic, @@ -214,12 +192,10 @@ private static void ProcessInlineElementsWithDecoration(Paragraph paragraph, Con /// /// Processes a link inline while applying emphasis decoration. /// - private static void ProcessLinkInlineWithDecoration(Paragraph paragraph, LinkInline link, Decoration emphasisDecoration, Theme theme) - { + private static void ProcessLinkInlineWithDecoration(Paragraph paragraph, LinkInline link, Decoration emphasisDecoration, Theme theme) { // Use link text if available, otherwise use URL string linkText = ExtractInlineText(link); - if (string.IsNullOrEmpty(linkText)) - { + if (string.IsNullOrEmpty(linkText)) { linkText = link.Url ?? ""; } @@ -237,8 +213,7 @@ private static void ProcessLinkInlineWithDecoration(Paragraph paragraph, LinkInl /// /// Processes inline code elements with syntax highlighting. /// - private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Theme theme) - { + private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Theme theme) { // Get theme colors for inline code string[] codeScopes = ["markup.inline.raw"]; (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( @@ -256,17 +231,15 @@ private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Them /// /// Processes link inline elements with clickable links using Spectre.Console Style with link parameter. /// - private static void ProcessLinkInline(Paragraph paragraph, LinkInline link, Theme theme) - { + private static void ProcessLinkInline(Paragraph paragraph, LinkInline link, Theme theme) { // Use link text if available, otherwise use URL string linkText = ExtractInlineText(link); - if (string.IsNullOrEmpty(linkText)) - { + if (string.IsNullOrEmpty(linkText)) { linkText = link.Url ?? ""; } // Get theme colors for links - string[] linkScopes = new[] { "markup.underline.link" }; + string[] linkScopes = ["markup.underline.link"]; (int linkFg, int linkBg, FontStyle linkFs) = TokenProcessor.ExtractThemeProperties( new MarkdownToken(linkScopes), theme); @@ -289,11 +262,9 @@ private static void ProcessLinkInline(Paragraph paragraph, LinkInline link, Them /// /// Processes Markdig AutolinkInline (URLs/emails detected by UseAutoLinks). /// - private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline autoLink, Theme theme) - { + private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline autoLink, Theme theme) { string url = autoLink.Url ?? string.Empty; - if (string.IsNullOrEmpty(url)) - { + if (string.IsNullOrEmpty(url)) { // Nothing to render return; } @@ -321,9 +292,8 @@ private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline au /// /// Extracts plain text from inline elements without markup. /// - private static string ExtractInlineText(Inline inline) - { - var builder = new System.Text.StringBuilder(); + private static string ExtractInlineText(Inline inline) { + var builder = new StringBuilder(); ExtractInlineTextRecursive(inline, builder); return builder.ToString(); } @@ -336,26 +306,22 @@ private sealed record TextSegment(string Text, bool IsUsername); /// /// Tries to parse username links (@username) from literal text. /// - private static bool TryParseUsernameLinks(string text, out TextSegment[] segments) - { + private static bool TryParseUsernameLinks(string text, out TextSegment[] segments) { var segmentList = new List(); // Simple regex to find @username patterns - var usernamePattern = RegNumLet(); + Regex usernamePattern = RegNumLet(); MatchCollection matches = usernamePattern.Matches(text); - if (matches.Count == 0) - { + if (matches.Count == 0) { segments = []; return false; } int lastIndex = 0; - foreach (System.Text.RegularExpressions.Match match in matches) - { + foreach (Match match in matches) { // Add text before the username - if (match.Index > lastIndex) - { + if (match.Index > lastIndex) { segmentList.Add(new TextSegment(text[lastIndex..match.Index], false)); } @@ -365,40 +331,36 @@ private static bool TryParseUsernameLinks(string text, out TextSegment[] segment } // Add remaining text - if (lastIndex < text.Length) - { + if (lastIndex < text.Length) { segmentList.Add(new TextSegment(text[lastIndex..], false)); } - segments = segmentList.ToArray(); + segments = [.. segmentList]; return true; } - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) - { - switch (inline) - { + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { + switch (inline) { case LiteralInline literal: builder.Append(literal.Content.ToString()); break; case ContainerInline container: - foreach (Inline child in container) - { + foreach (Inline child in container) { ExtractInlineTextRecursive(child, builder); } break; case LeafInline leaf: - if (leaf is CodeInline code) - { + if (leaf is CodeInline code) { builder.Append(code.Content); } - else if (leaf is LineBreakInline) - { + else if (leaf is LineBreakInline) { builder.Append('\n'); } break; + default: + break; } } diff --git a/src/Core/Markdown/Renderers/QuoteRenderer.cs b/src/Core/Markdown/Renderers/QuoteRenderer.cs index c342587..c6d04a3 100644 --- a/src/Core/Markdown/Renderers/QuoteRenderer.cs +++ b/src/Core/Markdown/Renderers/QuoteRenderer.cs @@ -9,16 +9,14 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// /// Renders markdown quote blocks. /// -internal static class QuoteRenderer -{ +internal static class QuoteRenderer { /// /// Renders a quote block with a bordered panel. /// /// The quote block to render /// Theme for styling /// Rendered quote in a bordered panel - public static IRenderable Render(QuoteBlock quote, Theme theme) - { + public static IRenderable Render(QuoteBlock quote, Theme theme) { string quoteText = ExtractQuoteText(quote, theme); return new Panel(new Markup(Markup.Escape(quoteText))) @@ -29,20 +27,16 @@ public static IRenderable Render(QuoteBlock quote, Theme theme) /// /// Extracts text content from all blocks within the quote. /// - private static string ExtractQuoteText(QuoteBlock quote, Theme theme) - { + private static string ExtractQuoteText(QuoteBlock quote, Theme theme) { string quoteText = string.Empty; - foreach (Block subBlock in quote) - { - if (subBlock is ParagraphBlock para) - { + foreach (Block subBlock in quote) { + if (subBlock is ParagraphBlock para) { var quoteBuilder = new StringBuilder(); InlineProcessor.ExtractInlineText(para.Inline, theme, quoteBuilder); quoteText += quoteBuilder.ToString(); } - else - { + else { quoteText += subBlock.ToString(); } } diff --git a/src/Core/Markdown/Renderers/TableRenderer.cs b/src/Core/Markdown/Renderers/TableRenderer.cs index f62b0f7..b330c95 100644 --- a/src/Core/Markdown/Renderers/TableRenderer.cs +++ b/src/Core/Markdown/Renderers/TableRenderer.cs @@ -1,12 +1,12 @@ -using Markdig.Extensions.Tables; +using System.Text; +using Markdig.Extensions.Tables; using Markdig.Helpers; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using PwshSpectreConsole.TextMate.Helpers; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using System.Text; -using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -14,8 +14,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// Table renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and provides proper color support. /// -internal static class TableRenderer -{ +internal static class TableRenderer { /// /// Renders a markdown table by building Spectre.Console Table objects directly. /// This approach provides proper theme color support and eliminates VT escaping issues. @@ -23,14 +22,14 @@ internal static class TableRenderer /// The table block to render /// Theme for styling /// Rendered table with proper styling - public static IRenderable? Render(Markdig.Extensions.Tables.Table table, Theme theme) - { - var spectreTable = new Spectre.Console.Table(); - spectreTable.ShowFooters = false; + public static IRenderable? Render(Markdig.Extensions.Tables.Table table, Theme theme) { + var spectreTable = new Spectre.Console.Table { + ShowFooters = false, - // Configure table appearance - spectreTable.Border = TableBorder.Rounded; - spectreTable.BorderStyle = GetTableBorderStyle(theme); + // Configure table appearance + Border = TableBorder.Rounded, + BorderStyle = GetTableBorderStyle(theme) + }; List<(bool isHeader, List cells)> allRows = ExtractTableDataOptimized(table, theme); @@ -39,18 +38,14 @@ internal static class TableRenderer // Add headers if present (bool isHeader, List cells) headerRow = allRows.FirstOrDefault(r => r.isHeader); - if (headerRow.cells?.Count > 0) - { - for (int i = 0; i < headerRow.cells.Count; i++) - { + if (headerRow.cells?.Count > 0) { + for (int i = 0; i < headerRow.cells.Count; i++) { TableCellContent cell = headerRow.cells[i]; // Use constructor to set header text; this is the most compatible way var column = new TableColumn(cell.Text); // Apply alignment if Markdig specified one for the column - if (i < table.ColumnDefinitions.Count) - { - column.Alignment = table.ColumnDefinitions[i].Alignment switch - { + if (i < table.ColumnDefinitions.Count) { + column.Alignment = table.ColumnDefinitions[i].Alignment switch { TableColumnAlign.Left => Justify.Left, TableColumnAlign.Center => Justify.Center, TableColumnAlign.Right => Justify.Right, @@ -60,20 +55,15 @@ internal static class TableRenderer spectreTable.AddColumn(column); } } - else - { + else { // No explicit headers, use first row as headers (bool isHeader, List cells) = allRows.FirstOrDefault(); - if (cells?.Count > 0) - { - for (int i = 0; i < cells.Count; i++) - { + if (cells?.Count > 0) { + for (int i = 0; i < cells.Count; i++) { TableCellContent cell = cells[i]; var column = new TableColumn(cell.Text); - if (i < table.ColumnDefinitions.Count) - { - column.Alignment = table.ColumnDefinitions[i].Alignment switch - { + if (i < table.ColumnDefinitions.Count) { + column.Alignment = table.ColumnDefinitions[i].Alignment switch { TableColumnAlign.Left => Justify.Left, TableColumnAlign.Center => Justify.Center, TableColumnAlign.Right => Justify.Right, @@ -87,13 +77,10 @@ internal static class TableRenderer } // Add data rows - foreach ((bool isHeader, List? cells) in allRows.Where(r => !r.isHeader)) - { - if (cells?.Count > 0) - { + foreach ((bool isHeader, List? cells) in allRows.Where(r => !r.isHeader)) { + if (cells?.Count > 0) { var rowCells = new List(); - foreach (TableCellContent? cell in cells) - { + foreach (TableCellContent? cell in cells) { Style cellStyle = GetCellStyle(theme); rowCells.Add(new Text(cell.Text, cellStyle)); } @@ -113,19 +100,15 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment /// Extracts table data with optimized cell content processing. /// internal static List<(bool isHeader, List cells)> ExtractTableDataOptimized( - Markdig.Extensions.Tables.Table table, Theme theme) - { + Markdig.Extensions.Tables.Table table, Theme theme) { var result = new List<(bool isHeader, List cells)>(); - foreach (Markdig.Extensions.Tables.TableRow row in table) - { + foreach (Markdig.Extensions.Tables.TableRow row in table.Cast()) { bool isHeader = row.IsHeader; var cells = new List(); - for (int i = 0; i < row.Count; i++) - { - if (row[i] is TableCell cell) - { + for (int i = 0; i < row.Count; i++) { + if (row[i] is TableCell cell) { string cellText = ExtractCellTextOptimized(cell, theme); TableColumnAlign? alignment = i < table.ColumnDefinitions.Count ? table.ColumnDefinitions[i].Alignment : null; cells.Add(new TableCellContent(cellText, alignment)); @@ -141,18 +124,14 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment /// /// Extracts text from table cells using optimized inline processing. /// - private static string ExtractCellTextOptimized(TableCell cell, Theme theme) - { - var textBuilder = StringBuilderPool.Rent(); + private static string ExtractCellTextOptimized(TableCell cell, Theme theme) { + StringBuilder textBuilder = StringBuilderPool.Rent(); - foreach (Block block in cell) - { - if (block is ParagraphBlock paragraph && paragraph.Inline is not null) - { + foreach (Block block in cell) { + if (block is ParagraphBlock paragraph && paragraph.Inline is not null) { ExtractInlineTextOptimized(paragraph.Inline, textBuilder); } - else if (block is Markdig.Syntax.CodeBlock code) - { + else if (block is CodeBlock code) { textBuilder.Append(code.Lines.ToString()); } } @@ -165,18 +144,14 @@ private static string ExtractCellTextOptimized(TableCell cell, Theme theme) /// /// Extracts text from inline elements optimized for table cells. /// - private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBuilder builder) - { + private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBuilder builder) { // Small optimization: use a borrowed buffer for frequently accessed literal content instead of repeated ToString allocations. - foreach (Inline inline in inlines) - { - switch (inline) - { + foreach (Inline inline in inlines) { + switch (inline) { case LiteralInline literal: // Append span directly from the underlying string to avoid creating intermediate allocations StringSlice slice = literal.Content; - if (slice.Text is not null && slice.Length > 0) - { + if (slice.Text is not null && slice.Length > 0) { builder.Append(slice.Text.AsSpan(slice.Start, slice.Length)); } break; @@ -203,52 +178,43 @@ private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBu /// /// Recursively extracts text from inline elements. /// - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) - { - switch (inline) - { + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { + switch (inline) { case LiteralInline literal: - if (literal.Content.Text is not null && literal.Content.Length > 0) - { + if (literal.Content.Text is not null && literal.Content.Length > 0) { builder.Append(literal.Content.Text.AsSpan(literal.Content.Start, literal.Content.Length)); } break; case ContainerInline container: - foreach (Inline child in container) - { + foreach (Inline child in container) { ExtractInlineTextRecursive(child, builder); } break; case LeafInline leaf: - if (leaf is CodeInline code) - { + if (leaf is CodeInline code) { builder.Append(code.Content); } break; + default: + break; } } /// /// Gets the border style for tables based on theme. /// - private static Style GetTableBorderStyle(Theme theme) - { + private static Style GetTableBorderStyle(Theme theme) { string[] borderScopes = ["punctuation.definition.table"]; Style? style = TokenProcessor.GetStyleForScopes(borderScopes, theme); - if (style is not null) - { - return style; - } - return new Style(foreground: Color.Grey); + return style is not null ? style : new Style(foreground: Color.Grey); } /// /// Gets the header style for table headers. /// - private static Style GetHeaderStyle(Theme theme) - { + private static Style GetHeaderStyle(Theme theme) { string[] headerScopes = ["markup.heading.table"]; Style? baseStyle = TokenProcessor.GetStyleForScopes(headerScopes, theme); Color fgColor = baseStyle?.Foreground ?? Color.Yellow; @@ -260,8 +226,7 @@ private static Style GetHeaderStyle(Theme theme) /// /// Gets the cell style for table data cells. /// - private static Style GetCellStyle(Theme theme) - { + private static Style GetCellStyle(Theme theme) { string[] cellScopes = ["markup.table.cell"]; Style? baseStyle = TokenProcessor.GetStyleForScopes(cellScopes, theme); Color fgColor = baseStyle?.Foreground ?? Color.White; diff --git a/src/Core/Markdown/Types/MarkdownTypes.cs b/src/Core/Markdown/Types/MarkdownTypes.cs index f61c933..7e09611 100644 --- a/src/Core/Markdown/Types/MarkdownTypes.cs +++ b/src/Core/Markdown/Types/MarkdownTypes.cs @@ -8,8 +8,7 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Types; /// Represents the result of rendering a markdown block element. /// Provides type safety and better error handling for rendering operations. /// -public sealed record MarkdownRenderResult -{ +public sealed record MarkdownRenderResult { /// /// The rendered element that can be displayed by Spectre.Console. /// @@ -52,26 +51,35 @@ public static MarkdownRenderResult CreateUnsupported(MarkdownBlockType blockType /// /// Enumeration of supported markdown block types for better type safety. /// -public enum MarkdownBlockType -{ +public enum MarkdownBlockType { + /// Unknown or unrecognized block type. Unknown, + /// Heading block (h1-h6). Heading, + /// Paragraph block with inline content. Paragraph, + /// List block (ordered or unordered). List, + /// Fenced code block with syntax highlighting. FencedCodeBlock, + /// Indented code block. CodeBlock, + /// Table block with cells and rows. Table, + /// Block quote or indented text. Quote, + /// Raw HTML block (sanitized for security). HtmlBlock, + /// Thematic break or horizontal rule. ThematicBreak, + /// Task list with checkboxes. TaskList } /// /// Configuration options for markdown rendering with validation. /// -public sealed record MarkdownRenderOptions -{ +public sealed record MarkdownRenderOptions { /// /// The theme to use for rendering. /// @@ -100,8 +108,7 @@ public sealed record MarkdownRenderOptions /// /// Validates the render options. /// - public void Validate() - { + public void Validate() { if (MaxRenderingDepth <= 0) throw new ArgumentException("MaxRenderingDepth must be greater than 0", nameof(MaxRenderingDepth)); } @@ -110,8 +117,7 @@ public void Validate() /// /// Represents inline rendering context with type safety. /// -public sealed record InlineRenderContext -{ +public sealed record InlineRenderContext { /// /// The theme for styling. /// diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index edcc40d..e59358f 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -12,8 +12,7 @@ namespace PwshSpectreConsole.TextMate.Core; /// Legacy string-based renderer was removed in favor of the object-based Markdig renderer for better performance /// and to eliminate VT escape sequence issues. /// -internal static class MarkdownRenderer -{ +internal static class MarkdownRenderer { /// /// Renders Markdown content with special handling for links and enhanced formatting. /// @@ -23,8 +22,7 @@ internal static class MarkdownRenderer /// Theme name for passing to Markdig renderer /// Optional debug callback (not used by Markdig renderer) /// Rendered rows with markdown syntax highlighting - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) - { + public static Rows Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) { string markdown = string.Join("\n", lines); return Markdown.MarkdownRenderer.Render(markdown, theme, themeName); } diff --git a/src/Core/MarkdownToken.cs b/src/Core/MarkdownToken.cs index 113569a..7eb02b2 100644 --- a/src/Core/MarkdownToken.cs +++ b/src/Core/MarkdownToken.cs @@ -5,16 +5,10 @@ namespace PwshSpectreConsole.TextMate.Core; /// /// Simple token for theme lookup from a set of scopes (for markdown elements). /// -internal sealed class MarkdownToken : IToken -{ +internal sealed class MarkdownToken(IEnumerable scopes) : IToken { public string Text { get; set; } = string.Empty; public int StartIndex { get; set; } public int EndIndex { get; set; } public int Length { get; set; } - public List Scopes { get; } - - public MarkdownToken(IEnumerable scopes) - { - Scopes = [.. scopes]; - } + public List Scopes { get; } = [.. scopes]; } diff --git a/src/Core/RenderableBatch.cs b/src/Core/RenderableBatch.cs index a0885eb..7074bfc 100644 --- a/src/Core/RenderableBatch.cs +++ b/src/Core/RenderableBatch.cs @@ -1,49 +1,59 @@ -using Spectre.Console.Rendering; -using Spectre.Console; +using Spectre.Console; +using Spectre.Console.Rendering; namespace PwshSpectreConsole.TextMate.Core; -public sealed class RenderableBatch : IRenderable -{ - public IRenderable[] Renderables { get; } +/// +/// A batch container for Spectre.Console renderables used for streaming output in multiple chunks. +/// +public sealed class RenderableBatch(IRenderable[] renderables, int batchIndex = 0, long fileOffset = 0) : IRenderable { + /// + /// Array of renderables that comprise this batch. + /// + public IRenderable[] Renderables { get; } = renderables ?? []; /// /// Zero-based batch index for ordering when streaming. /// - public int BatchIndex { get; } + public int BatchIndex { get; } = batchIndex; /// /// Zero-based file offset (starting line number) for this batch. /// - public long FileOffset { get; } + public long FileOffset { get; } = fileOffset; /// /// Number of rendered lines (rows) in this batch. /// public int LineCount => Renderables?.Length ?? 0; - public RenderableBatch(IRenderable[] renderables, int batchIndex = 0, long fileOffset = 0) - { - Renderables = renderables ?? []; - BatchIndex = batchIndex; - FileOffset = fileOffset; - } - - public IEnumerable Render(RenderOptions options, int maxWidth) - { - foreach (IRenderable r in Renderables) - { + /// + /// Renders all contained renderables as segments for Spectre.Console output. + /// + /// Render options specifying terminal constraints + /// Maximum width available for rendering + /// Enumerable of render segments from all renderables in this batch + public IEnumerable Render(RenderOptions options, int maxWidth) { + foreach (IRenderable r in Renderables) { foreach (Segment s in r.Render(options, maxWidth)) yield return s; } } - public Measurement Measure(RenderOptions options, int maxWidth) - { + /// + /// Measures the rendering dimensions of all renderables in this batch. + /// + /// Render options specifying terminal constraints + /// Maximum width available for measurement + /// Measurement indicating minimum and maximum width needed + public Measurement Measure(RenderOptions options, int maxWidth) => // Return a conservative, permissive measurement: min = 0, max = maxWidth. // This avoids depending on concrete Measurement properties across Spectre.Console versions. - return new Measurement(0, maxWidth); - } + new(0, maxWidth); + /// + /// Converts this batch to a Spectre.Console Rows object for rendering. + /// + /// Spectre.Console Rows containing all renderables from this batch public Spectre.Console.Rows ToSpectreRows() => new(Renderables); } diff --git a/src/Core/Rows.cs b/src/Core/Rows.cs index ddb76f1..1ce041f 100644 --- a/src/Core/Rows.cs +++ b/src/Core/Rows.cs @@ -5,7 +5,9 @@ namespace PwshSpectreConsole.TextMate.Core; /// /// Container for rendered rows returned by renderers. /// -public sealed record Rows(IRenderable[] Renderables) -{ +public sealed record Rows(IRenderable[] Renderables) { + /// + /// Returns an empty Rows container with no renderables. + /// public static Rows Empty { get; } = new Rows([]); } diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 50fafd7..385be10 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -1,9 +1,9 @@ using System.Text; +using PwshSpectreConsole.TextMate.Helpers; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core; @@ -11,8 +11,7 @@ namespace PwshSpectreConsole.TextMate.Core; /// Provides optimized rendering for standard (non-Markdown) TextMate grammars. /// Implements object pooling and batch processing for better performance. /// -internal static class StandardRenderer -{ +internal static class StandardRenderer { /// /// Renders text lines using standard TextMate grammar processing. /// Uses object pooling and batch processing for optimal performance. @@ -21,21 +20,15 @@ internal static class StandardRenderer /// Theme to apply /// Grammar for tokenization /// Rendered rows with syntax highlighting - public static Rows Render(string[] lines, Theme theme, IGrammar grammar) - { - return Render(lines, theme, grammar, null); - } + public static Rows Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar, null); - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) - { - var builder = StringBuilderPool.Rent(); + public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) { + StringBuilder builder = StringBuilderPool.Rent(); List rows = new(lines.Length); - try - { + try { IStateStack? ruleStack = null; - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) - { + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { string line = lines[lineIndex]; ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); ruleStack = result.RuleStack; @@ -47,16 +40,13 @@ public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action< return new Rows([.. rows]); } - catch (ArgumentException ex) - { + catch (ArgumentException ex) { throw new InvalidOperationException($"Argument error during rendering: {ex.Message}", ex); } - catch (Exception ex) - { + catch (Exception ex) { throw new InvalidOperationException($"Unexpected error during rendering: {ex.Message}", ex); } - finally - { + finally { StringBuilderPool.Return(builder); } } diff --git a/src/Core/StyleHelper.cs b/src/Core/StyleHelper.cs index 4aac03d..fdc8fda 100644 --- a/src/Core/StyleHelper.cs +++ b/src/Core/StyleHelper.cs @@ -7,30 +7,21 @@ namespace PwshSpectreConsole.TextMate.Core; /// Provides utility methods for style and color conversion operations. /// Handles conversion between TextMate and Spectre Console styling systems. /// -internal static class StyleHelper -{ +internal static class StyleHelper { /// /// Converts a theme color ID to a Spectre Console Color. /// /// Color ID from theme /// Theme containing color definitions /// Spectre Console Color instance - public static Color GetColor(int colorId, Theme theme) - { - if (colorId == -1) - { - return Color.Default; - } - return HexToColor(theme.GetColor(colorId)); - } + public static Color GetColor(int colorId, Theme theme) => colorId == -1 ? Color.Default : HexToColor(theme.GetColor(colorId)); /// /// Converts TextMate font style to Spectre Console decoration. /// /// TextMate font style /// Spectre Console decoration - public static Decoration GetDecoration(FontStyle fontStyle) - { + public static Decoration GetDecoration(FontStyle fontStyle) { Decoration result = Decoration.None; if (fontStyle == FontStyle.NotSet) return result; @@ -48,10 +39,8 @@ public static Decoration GetDecoration(FontStyle fontStyle) /// /// Hex color string (with or without #) /// Spectre Console Color instance - public static Color HexToColor(string hexString) - { - if (hexString.StartsWith('#')) - { + public static Color HexToColor(string hexString) { + if (hexString.StartsWith('#')) { hexString = hexString[1..]; } diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index b92cafe..19cbb04 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -1,7 +1,7 @@ using System.Text; -using PwshSpectreConsole.TextMate.Infrastructure; using PwshSpectreConsole.TextMate.Extensions; using PwshSpectreConsole.TextMate.Helpers; +using PwshSpectreConsole.TextMate.Infrastructure; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; @@ -13,8 +13,7 @@ namespace PwshSpectreConsole.TextMate.Core; /// Main entry point for TextMate processing operations. /// Provides high-performance text processing using TextMate grammars and themes. /// -public static class TextMateProcessor -{ +public static class TextMateProcessor { /// /// Processes string lines with specified theme and grammar for syntax highlighting. /// This is the unified method that handles all text processing scenarios. @@ -26,52 +25,48 @@ public static class TextMateProcessor /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) - { + public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - if (lines.Length == 0 || lines.AllIsNullOrEmpty()) - { - return null; - } - - return ProcessLines(lines, themeName, grammarId, isExtension, null); + return lines.Length == 0 || lines.AllIsNullOrEmpty() ? null : ProcessLines(lines, themeName, grammarId, isExtension, null); } - public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, Action? debugCallback) - { + /// + /// Processes string lines for code blocks without escaping markup characters. + /// This preserves raw source code content for proper syntax highlighting. + /// + /// Array of text lines to process + /// Theme to apply for styling + /// Language ID or file extension for grammar selection + /// True if grammarId is a file extension, false if it's a language ID + /// Optional callback for debugging token information + /// Rendered rows with syntax highlighting, or null if processing fails + /// Thrown when is null + /// Thrown when grammar cannot be found or processing encounters an error + public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, Action? debugCallback) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - if (lines.Length == 0 || lines.AllIsNullOrEmpty()) - { + if (lines.Length == 0 || lines.AllIsNullOrEmpty()) { return null; } - try - { + try { (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); // Resolve grammar using CacheManager which knows how to map language ids and extensions - IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); - if (grammar is null) - { - throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); - } + IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); // Use optimized rendering based on grammar type return grammar.GetName() == "Markdown" ? MarkdownRenderer.Render(lines, theme, grammar, themeName, debugCallback) : StandardRenderer.Render(lines, theme, grammar, debugCallback); } - catch (InvalidOperationException) - { + catch (InvalidOperationException) { throw; } - catch (ArgumentException ex) - { + catch (ArgumentException ex) { throw new InvalidOperationException($"Argument error processing lines with grammar '{grammarId}': {ex.Message}", ex); } - catch (Exception ex) - { + catch (Exception ex) { throw new InvalidOperationException($"Unexpected error processing lines with grammar '{grammarId}': {ex.Message}", ex); } } @@ -87,17 +82,14 @@ public static class TextMateProcessor /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static Rows? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) - { + public static Rows? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - try - { + try { (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); - if (grammar is null) - { + if (grammar is null) { string errorMessage = isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"; @@ -107,16 +99,13 @@ public static class TextMateProcessor // Always use StandardRenderer for code blocks, never MarkdownRenderer return RenderCodeBlock(lines, theme, grammar); } - catch (InvalidOperationException) - { + catch (InvalidOperationException) { throw; } - catch (ArgumentException ex) - { + catch (ArgumentException ex) { throw new InvalidOperationException($"Argument error processing code block with grammar '{grammarId}': {ex.Message}", ex); } - catch (Exception ex) - { + catch (Exception ex) { throw new InvalidOperationException($"Unexpected error processing code block with grammar '{grammarId}': {ex.Message}", ex); } } @@ -124,16 +113,13 @@ public static class TextMateProcessor /// /// Renders code block lines without escaping markup characters. /// - private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) - { - var builder = StringBuilderPool.Rent(); - try - { + private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) { + StringBuilder builder = StringBuilderPool.Rent(); + try { List rows = new(lines.Length); IStateStack? ruleStack = null; - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) - { + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { string line = lines[lineIndex]; ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); ruleStack = result.RuleStack; @@ -145,10 +131,9 @@ private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar gramma builder.Clear(); } - return new Rows(rows.ToArray()); + return new Rows([.. rows]); } - finally - { + finally { StringBuilderPool.Return(builder); } } @@ -181,9 +166,8 @@ public static IEnumerable ProcessLinesInBatches( ThemeName themeName, string grammarId, bool isExtension = false, - CancellationToken cancellationToken = default, - IProgress<(int batchIndex, long linesProcessed)>? progress = null) - { + IProgress<(int batchIndex, long linesProcessed)>? progress = null, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize, nameof(batchSize)); @@ -197,27 +181,19 @@ public static IEnumerable ProcessLinesInBatches( // LoadGrammar calls or cross-registry caching can cause duplicate-key exceptions. (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); // Resolve grammar using CacheManager which knows how to map language ids and extensions - IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); - if (grammar is null) - { - throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); - } - + IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); bool useMarkdownRenderer = grammar.GetName() == "Markdown"; - foreach (string? line in lines) - { + foreach (string? line in lines) { cancellationToken.ThrowIfCancellationRequested(); buffer.Add(line ?? string.Empty); - if (buffer.Count >= batchSize) - { + if (buffer.Count >= batchSize) { // Render the batch using the already-loaded grammar and theme Rows? result = useMarkdownRenderer ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) : StandardRenderer.Render([.. buffer], theme, grammar, null); - if (result is not null) - { + if (result is not null) { yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex, fileOffset: fileOffset); progress?.Report((batchIndex, fileOffset + batchSize)); batchIndex++; @@ -228,15 +204,13 @@ public static IEnumerable ProcessLinesInBatches( } // Process remaining lines - if (buffer.Count > 0) - { + if (buffer.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); Rows? result = useMarkdownRenderer ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) : StandardRenderer.Render([.. buffer], theme, grammar, null); - if (result is not null) - { + if (result is not null) { yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex, fileOffset: fileOffset); progress?.Report((batchIndex, fileOffset + buffer.Count)); } @@ -246,10 +220,7 @@ public static IEnumerable ProcessLinesInBatches( /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) - { - return ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, CancellationToken.None, null); - } + public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); /// /// Helper to stream a file by reading lines lazily and processing them in batches. @@ -273,18 +244,15 @@ public static IEnumerable ProcessFileInBatches( ThemeName themeName, string grammarId, bool isExtension = false, - CancellationToken cancellationToken = default, - IProgress<(int batchIndex, long linesProcessed)>? progress = null) - { - if (!File.Exists(filePath)) throw new FileNotFoundException(filePath); - return ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension, cancellationToken, progress); + IProgress<(int batchIndex, long linesProcessed)>? progress = null, + CancellationToken cancellationToken = default) { + return !File.Exists(filePath) + ? throw new FileNotFoundException(filePath) + : ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension, progress, cancellationToken); } /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) - { - return ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, CancellationToken.None, null); - } + public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); } diff --git a/src/Core/TokenDebugInfo.cs b/src/Core/TokenDebugInfo.cs index 9b1cc4c..34d8a68 100644 --- a/src/Core/TokenDebugInfo.cs +++ b/src/Core/TokenDebugInfo.cs @@ -4,16 +4,48 @@ namespace PwshSpectreConsole.TextMate.Core; -public class TokenDebugInfo -{ +/// +/// Contains debug information for a single parsed token including position, scope, and styling details. +/// +public class TokenDebugInfo { + /// + /// Line number of this token (zero-based index). + /// public int? LineIndex { get; set; } + /// + /// Starting character position of this token in the line. + /// public int StartIndex { get; set; } + /// + /// Ending character position of this token in the line. + /// public int EndIndex { get; set; } + /// + /// The actual text content of this token. + /// public string? Text { get; set; } + /// + /// List of scopes that apply to this token (for theme matching). + /// public List? Scopes { get; set; } + /// + /// Foreground color ID from theme (negative if not set). + /// public int Foreground { get; set; } + /// + /// Background color ID from theme (negative if not set). + /// public int Background { get; set; } + /// + /// Font style applied to this token (bold, italic, underline). + /// public FontStyle FontStyle { get; set; } + /// + /// Resolved Spectre.Console style for rendering this token. + /// public Style? Style { get; set; } + /// + /// Theme color dictionary used for rendering this token. + /// public ReadOnlyDictionary? Theme { get; set; } } diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index ae04097..fb5276d 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -1,10 +1,10 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Text; +using PwshSpectreConsole.TextMate.Extensions; using Spectre.Console; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Extensions; -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; namespace PwshSpectreConsole.TextMate.Core; @@ -12,8 +12,7 @@ namespace PwshSpectreConsole.TextMate.Core; /// Provides optimized token processing and styling operations. /// Handles theme property extraction and token rendering with performance optimizations. /// -internal static class TokenProcessor -{ +internal static class TokenProcessor { // Toggle caching via environment variable PSTEXTMATE_DISABLE_STYLECACHE=1 to disable public static readonly bool EnableStyleCache = Environment.GetEnvironmentVariable("PSTEXTMATE_DISABLE_STYLECACHE") != "1"; // Cache theme extraction results per (scopesKey, themeInstanceHash) @@ -40,10 +39,8 @@ public static void ProcessTokensBatch( StringBuilder builder, Action? debugCallback = null, int? lineIndex = null, - bool escapeMarkup = true) - { - foreach (IToken token in tokens) - { + bool escapeMarkup = true) { + foreach (IToken token in tokens) { int startIndex = Math.Min(token.StartIndex, line.Length); int endIndex = Math.Min(token.EndIndex, line.Length); @@ -55,9 +52,8 @@ public static void ProcessTokensBatch( Style? style = GetStyleForScopes(token.Scopes, theme); // Only extract numeric theme properties when debugging is enabled to reduce work - (int foreground, int background, FontStyle fontStyle) = ( -1, -1, FontStyle.NotSet ); - if (debugCallback is not null) - { + (int foreground, int background, FontStyle fontStyle) = (-1, -1, FontStyle.NotSet); + if (debugCallback is not null) { (foreground, background, fontStyle) = ExtractThemeProperties(token, theme); } @@ -65,8 +61,7 @@ public static void ProcessTokensBatch( (string processedText, Style? resolvedStyle) = WriteTokenOptimizedReturn(textSpan, style, theme, escapeMarkup); builder.AppendWithStyle(resolvedStyle, processedText); - debugCallback?.Invoke(new TokenDebugInfo - { + debugCallback?.Invoke(new TokenDebugInfo { LineIndex = lineIndex, StartIndex = startIndex, EndIndex = endIndex, @@ -81,15 +76,13 @@ public static void ProcessTokensBatch( } } - public static (int foreground, int background, FontStyle fontStyle) ExtractThemeProperties(IToken token, Theme theme) - { + public static (int foreground, int background, FontStyle fontStyle) ExtractThemeProperties(IToken token, Theme theme) { // Build a compact key from token scopes (they're mostly immutable per token) string scopesKey = string.Join('\u001F', token.Scopes); int themeHash = RuntimeHelpers.GetHashCode(theme); - var cacheKey = (scopesKey, themeHash); + (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); - if (_themePropertyCache.TryGetValue(cacheKey, out (int fg, int bg, FontStyle fs) cached)) - { + if (_themePropertyCache.TryGetValue(cacheKey, out (int fg, int bg, FontStyle fs) cached)) { return (cached.fg, cached.bg, cached.fs); } @@ -97,8 +90,7 @@ public static (int foreground, int background, FontStyle fontStyle) ExtractTheme int background = -1; FontStyle fontStyle = FontStyle.NotSet; - foreach (ThemeTrieElementRule? themeRule in theme.Match(token.Scopes)) - { + foreach (ThemeTrieElementRule? themeRule in theme.Match(token.Scopes)) { if (foreground == -1 && themeRule.foreground > 0) foreground = themeRule.foreground; if (background == -1 && themeRule.background > 0) @@ -108,7 +100,7 @@ public static (int foreground, int background, FontStyle fontStyle) ExtractTheme } // Store in cache even if defaults (-1) for future lookups - var result = (foreground, background, fontStyle); + (int foreground, int background, FontStyle fontStyle) result = (foreground, background, fontStyle); _themePropertyCache.TryAdd(cacheKey, result); return result; } @@ -123,21 +115,18 @@ public static (string processedText, Style? style) WriteTokenOptimizedReturn( ReadOnlySpan text, Style? styleHint, Theme theme, - bool escapeMarkup = true) - { + bool escapeMarkup = true) { string processedText = escapeMarkup ? Markup.Escape(text.ToString()) : text.ToString(); // Early return for no styling needed - if (styleHint is null) - { + if (styleHint is null) { return (processedText, null); } // If the style serializes to an empty markup string, treat it as no style // to avoid emitting empty [] tags which Spectre.Markup rejects. string styleMarkup = styleHint.ToMarkup(); - if (string.IsNullOrEmpty(styleMarkup)) - { + if (string.IsNullOrEmpty(styleMarkup)) { return (processedText, null); } @@ -154,25 +143,19 @@ public static void WriteTokenOptimized( ReadOnlySpan text, Style? style, Theme theme, - bool escapeMarkup = true) - { + bool escapeMarkup = true) { // Fast-path: if no escaping needed, append span directly with style-aware overload - if (!escapeMarkup) - { - if (style is not null) - { + if (!escapeMarkup) { + if (style is not null) { string styleMarkup = style.ToMarkup(); - if (!string.IsNullOrEmpty(styleMarkup)) - { + if (!string.IsNullOrEmpty(styleMarkup)) { builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); } - else - { + else { builder.Append(text).AppendLine(); } } - else - { + else { builder.Append(text).AppendLine(); } return; @@ -180,32 +163,25 @@ public static void WriteTokenOptimized( // Check for presence of characters that require escaping. Most common tokens do not contain '[' or ']' bool needsEscape = false; - foreach (char c in text) - { - if (c == '[' || c == ']') - { + foreach (char c in text) { + if (c is '[' or ']') { needsEscape = true; break; } } - if (!needsEscape) - { + if (!needsEscape) { // Safe fast-path: append span directly - if (style is not null) - { + if (style is not null) { string styleMarkup = style.ToMarkup(); - if (!string.IsNullOrEmpty(styleMarkup)) - { + if (!string.IsNullOrEmpty(styleMarkup)) { builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); } - else - { + else { builder.Append(text).AppendLine(); } } - else - { + else { builder.Append(text).AppendLine(); } return; @@ -213,20 +189,16 @@ public static void WriteTokenOptimized( // Slow path: fallback to the reliable Markup.Escape for correctness when special characters are present string escaped = Markup.Escape(text.ToString()); - if (style is not null) - { + if (style is not null) { string styleMarkup = style.ToMarkup(); - if (!string.IsNullOrEmpty(styleMarkup)) - { + if (!string.IsNullOrEmpty(styleMarkup)) { builder.Append('[').Append(styleMarkup).Append(']').Append(escaped).Append("[/]").AppendLine(); } - else - { + else { builder.Append(escaped).AppendLine(); } } - else - { + else { builder.Append(escaped).AppendLine(); } } @@ -234,23 +206,20 @@ public static void WriteTokenOptimized( /// /// Returns a cached Style for the given scopes and theme. Returns null for default/no-style. /// - public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) - { + public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { string scopesKey = string.Join('\u001F', scopes); int themeHash = RuntimeHelpers.GetHashCode(theme); - var cacheKey = (scopesKey, themeHash); + (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); - if (_styleCache.TryGetValue(cacheKey, out Style? cached)) - { + if (_styleCache.TryGetValue(cacheKey, out Style? cached)) { return cached; } // Fallback to extracting properties and building a Style // Create a dummy token-like enumerable for existing ExtractThemeProperties method var token = new MarkdownToken([.. scopes]); - var (fg, bg, fs) = ExtractThemeProperties(token, theme); - if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) - { + (int fg, int bg, FontStyle fs) = ExtractThemeProperties(token, theme); + if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) { _styleCache.TryAdd(cacheKey, null); return null; } diff --git a/src/Core/Validation/MarkdownInputValidator.cs b/src/Core/Validation/MarkdownInputValidator.cs index 09a1060..ae973ca 100644 --- a/src/Core/Validation/MarkdownInputValidator.cs +++ b/src/Core/Validation/MarkdownInputValidator.cs @@ -8,8 +8,7 @@ namespace PwshSpectreConsole.TextMate.Core.Validation; /// Provides validation utilities for markdown input and rendering parameters. /// Helps prevent security issues and improves error handling. /// -internal static partial class MarkdownInputValidator -{ +internal static partial class MarkdownInputValidator { private const int MaxMarkdownLength = 1_000_000; // 1MB text limit private const int MaxLineCount = 10_000; private const int MaxLineLength = 50_000; @@ -25,8 +24,7 @@ internal static partial class MarkdownInputValidator /// /// The markdown text to validate /// Validation result with any errors - public static ValidationResult ValidateMarkdownInput(string? markdown) - { + public static ValidationResult ValidateMarkdownInput(string? markdown) { if (string.IsNullOrEmpty(markdown)) return ValidationResult.Success!; @@ -40,10 +38,8 @@ public static ValidationResult ValidateMarkdownInput(string? markdown) if (lines.Length > MaxLineCount) errors.Add($"Markdown content exceeds maximum line count of {MaxLineCount:N0}"); - foreach (string line in lines) - { - if (line.Length > MaxLineLength) - { + foreach (string line in lines) { + if (line.Length > MaxLineLength) { errors.Add($"Line exceeds maximum length of {MaxLineLength:N0} characters"); break; } @@ -67,18 +63,14 @@ public static ValidationResult ValidateMarkdownInput(string? markdown) /// /// The theme name to validate /// True if valid, false otherwise - public static bool IsValidThemeName(ThemeName themeName) - { - return Enum.IsDefined(typeof(ThemeName), themeName); - } + public static bool IsValidThemeName(ThemeName themeName) => Enum.IsDefined(typeof(ThemeName), themeName); /// /// Sanitizes URL input for link rendering. /// /// The URL to sanitize /// Sanitized URL or null if dangerous - public static string? SanitizeUrl(string? url) - { + public static string? SanitizeUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return null; @@ -87,11 +79,10 @@ public static bool IsValidThemeName(ThemeName themeName) return null; // Basic URL validation - if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) && - !Uri.TryCreate(url, UriKind.Relative, out uri)) - return null; - - return url.Trim(); + return !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) && + !Uri.TryCreate(url, UriKind.Relative, out uri) + ? null + : url.Trim(); } /// @@ -99,8 +90,7 @@ public static bool IsValidThemeName(ThemeName themeName) /// /// The language identifier /// True if supported, false otherwise - public static bool IsValidLanguage(string? language) - { + public static bool IsValidLanguage(string? language) { if (string.IsNullOrWhiteSpace(language)) return false; diff --git a/src/Extensions/SpanOptimizedStringExtensions.cs b/src/Extensions/SpanOptimizedStringExtensions.cs index b6fa1c8..cc9b716 100644 --- a/src/Extensions/SpanOptimizedStringExtensions.cs +++ b/src/Extensions/SpanOptimizedStringExtensions.cs @@ -6,8 +6,7 @@ namespace PwshSpectreConsole.TextMate.Extensions; /// Enhanced string manipulation methods optimized with Span operations. /// Provides significant performance improvements for text processing scenarios. /// -public static class SpanOptimizedStringExtensions -{ +public static class SpanOptimizedStringExtensions { /// /// Joins string arrays using span operations for better performance. /// Avoids multiple string allocations during concatenation. @@ -15,8 +14,7 @@ public static class SpanOptimizedStringExtensions /// Array of strings to join /// Separator character /// Joined string - public static string JoinOptimized(this string[] values, char separator) - { + public static string JoinOptimized(this string[] values, char separator) { if (values.Length == 0) return string.Empty; if (values.Length == 1) return values[0] ?? string.Empty; @@ -27,8 +25,7 @@ public static string JoinOptimized(this string[] values, char separator) var builder = new StringBuilder(totalLength); - for (int i = 0; i < values.Length; i++) - { + for (int i = 0; i < values.Length; i++) { if (i > 0) builder.Append(separator); if (values[i] is not null) builder.Append(values[i].AsSpan()); @@ -43,8 +40,7 @@ public static string JoinOptimized(this string[] values, char separator) /// Array of strings to join /// Separator string /// Joined string - public static string JoinOptimized(this string[] values, string separator) - { + public static string JoinOptimized(this string[] values, string separator) { if (values.Length == 0) return string.Empty; if (values.Length == 1) return values[0] ?? string.Empty; @@ -56,8 +52,7 @@ public static string JoinOptimized(this string[] values, string separator) var builder = new StringBuilder(totalLength); - for (int i = 0; i < values.Length; i++) - { + for (int i = 0; i < values.Length; i++) { if (i > 0 && separator is not null) builder.Append(separator.AsSpan()); if (values[i] is not null) @@ -76,8 +71,7 @@ public static string JoinOptimized(this string[] values, string separator) /// String split options /// Maximum expected number of splits for optimization /// Array of split strings - public static string[] SplitOptimized(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) - { + public static string[] SplitOptimized(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) { if (string.IsNullOrEmpty(source)) return []; @@ -86,17 +80,14 @@ public static string[] SplitOptimized(this string source, char[] separators, Str var results = new List(Math.Min(maxSplits, 64)); // Cap initial capacity int start = 0; - for (int i = 0; i <= sourceSpan.Length; i++) - { + for (int i = 0; i <= sourceSpan.Length; i++) { bool isSeparator = i < sourceSpan.Length && separators.Contains(sourceSpan[i]); bool isEnd = i == sourceSpan.Length; - if (isSeparator || isEnd) - { + if (isSeparator || isEnd) { ReadOnlySpan segment = sourceSpan[start..i]; - if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) - { + if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) { start = i + 1; continue; } @@ -109,7 +100,7 @@ public static string[] SplitOptimized(this string source, char[] separators, Str } } - return results.ToArray(); + return [.. results]; } /// @@ -118,8 +109,7 @@ public static string[] SplitOptimized(this string source, char[] separators, Str /// /// Source string to trim /// Trimmed string - public static string TrimOptimized(this string source) - { + public static string TrimOptimized(this string source) { if (string.IsNullOrEmpty(source)) return source ?? string.Empty; @@ -133,13 +123,7 @@ public static string TrimOptimized(this string source) /// Source string to search /// Characters to search for /// True if any character is found - public static bool ContainsAnyOptimized(this string source, ReadOnlySpan chars) - { - if (string.IsNullOrEmpty(source) || chars.IsEmpty) - return false; - - return source.AsSpan().IndexOfAny(chars) >= 0; - } + public static bool ContainsAnyOptimized(this string source, ReadOnlySpan chars) => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; /// /// Replaces characters in a string using span operations for better performance. @@ -148,8 +132,7 @@ public static bool ContainsAnyOptimized(this string source, ReadOnlySpan c /// Character to replace /// Replacement character /// String with replacements - public static string ReplaceOptimized(this string source, char oldChar, char newChar) - { + public static string ReplaceOptimized(this string source, char oldChar, char newChar) { if (string.IsNullOrEmpty(source)) return source ?? string.Empty; @@ -163,8 +146,7 @@ public static string ReplaceOptimized(this string source, char oldChar, char new var result = new StringBuilder(source.Length); int lastIndex = 0; - do - { + do { result.Append(sourceSpan[lastIndex..firstIndex]); result.Append(newChar); lastIndex = firstIndex + 1; diff --git a/src/Extensions/StringBuilderExtensions.cs b/src/Extensions/StringBuilderExtensions.cs index be023d7..b3cc50a 100644 --- a/src/Extensions/StringBuilderExtensions.cs +++ b/src/Extensions/StringBuilderExtensions.cs @@ -8,8 +8,7 @@ namespace PwshSpectreConsole.TextMate.Extensions; /// Provides optimized StringBuilder extension methods for text rendering operations. /// Reduces string allocations during the markup generation process. /// -public static class StringBuilderExtensions -{ +public static class StringBuilderExtensions { /// /// Appends a Spectre.Console link markup: [link=url]text[/] /// @@ -17,8 +16,7 @@ public static class StringBuilderExtensions /// The URL for the link /// The link text /// The same StringBuilder for method chaining - public static StringBuilder AppendLink(this StringBuilder builder, string url, string text) - { + public static StringBuilder AppendLink(this StringBuilder builder, string url, string text) { builder.Append("[link=") .Append(url.EscapeMarkup()) .Append(']') @@ -26,37 +24,49 @@ public static StringBuilder AppendLink(this StringBuilder builder, string url, s .Append("[/]"); return builder; } - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) - { - return AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); - } + /// + /// Appends an integer value with optional style using invariant culture formatting. + /// + /// StringBuilder to append to + /// Optional style to apply + /// Nullable integer to append + /// The same StringBuilder for method chaining + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) => AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) - { + /// + /// Appends a string value with optional style markup, escaping special characters. + /// + /// StringBuilder to append to + /// Optional style to apply + /// String text to append + /// The same StringBuilder for method chaining + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) { value ??= string.Empty; - if (style is not null) - { - return builder.Append('[') + return style is not null + ? builder.Append('[') .Append(style.ToMarkup()) .Append(']') .Append(value.EscapeMarkup()) - .Append("[/]"); - } - return builder.Append(value); + .Append("[/]") + : builder.Append(value); } - public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? style, string? value) - { + /// + /// Appends a string value with optional style markup and space separator, escaping special characters. + /// + /// StringBuilder to append to + /// Optional style to apply + /// String text to append + /// The same StringBuilder for method chaining + public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? style, string? value) { value ??= string.Empty; - if (style is not null) - { - return builder.Append('[') + return style is not null + ? builder.Append('[') .Append(style.ToMarkup()) .Append(']') .Append(value) - .Append("[/] "); - } - return builder.Append(value); + .Append("[/] ") + : builder.Append(value); } /// @@ -67,16 +77,13 @@ public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? /// Optional style to apply /// Text content to append /// The same StringBuilder for method chaining - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, ReadOnlySpan value) - { - if (style is not null) - { - return builder.Append('[') + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, ReadOnlySpan value) { + return style is not null + ? builder.Append('[') .Append(style.ToMarkup()) .Append(']') .Append(value) - .Append("[/]"); - } - return builder.Append(value); + .Append("[/]") + : builder.Append(value); } } diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs index eef9374..e169cf3 100644 --- a/src/Extensions/StringExtensions.cs +++ b/src/Extensions/StringExtensions.cs @@ -2,10 +2,9 @@ namespace PwshSpectreConsole.TextMate.Extensions; /// /// Provides optimized string manipulation methods using modern .NET performance patterns. -/// Uses Span and ReadOnlySpan to minimize memory allocations during text processing. +/// Uses Span and ReadOnlySpan to minimize memory allocations during text processing. /// -public static class StringExtensions -{ +public static class StringExtensions { /// /// Efficiently extracts substring using Span to avoid string allocations. /// This is significantly faster than traditional substring operations for large text processing. @@ -14,14 +13,10 @@ public static class StringExtensions /// Starting index for substring /// Ending index for substring /// ReadOnlySpan representing the substring - public static ReadOnlySpan SubstringAsSpan(this string source, int startIndex, int endIndex) - { - if (startIndex < 0 || endIndex > source.Length || startIndex > endIndex) - { - return ReadOnlySpan.Empty; - } - - return source.AsSpan(startIndex, endIndex - startIndex); + public static ReadOnlySpan SubstringAsSpan(this string source, int startIndex, int endIndex) { + return startIndex < 0 || endIndex > source.Length || startIndex > endIndex + ? [] + : source.AsSpan(startIndex, endIndex - startIndex); } /// @@ -32,8 +27,7 @@ public static ReadOnlySpan SubstringAsSpan(this string source, int startIn /// Starting index for substring /// Ending index for substring /// Substring as string, or empty string if invalid indexes - public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) - { + public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) { ReadOnlySpan span = source.SubstringAsSpan(startIndex, endIndex); return span.IsEmpty ? string.Empty : span.ToString(); } @@ -44,8 +38,5 @@ public static string SubstringAtIndexes(this string source, int startIndex, int /// /// Array of strings to check /// True if all strings are null or empty, false otherwise - public static bool AllIsNullOrEmpty(this string[] strings) - { - return strings.All(string.IsNullOrEmpty); - } + public static bool AllIsNullOrEmpty(this string[] strings) => strings.All(string.IsNullOrEmpty); } diff --git a/src/Extensions/ThemeExtensions.cs b/src/Extensions/ThemeExtensions.cs index de2a84d..ad77831 100644 --- a/src/Extensions/ThemeExtensions.cs +++ b/src/Extensions/ThemeExtensions.cs @@ -1,20 +1,20 @@ -using Spectre.Console; -using PwshSpectreConsole.TextMate.Core; +using PwshSpectreConsole.TextMate.Core; +using Spectre.Console; using TextMateSharp.Themes; namespace PwshSpectreConsole.TextMate.Extensions; -public static class ThemeExtensions -{ + +/// +/// Extension methods for converting TextMate themes and colors to Spectre.Console styling. +/// +public static class ThemeExtensions { /// /// Converts a TextMate theme to a Spectre.Console style. /// This is a placeholder - actual theming should be done via scope-based lookups. /// /// The TextMate theme to convert. /// A Spectre.Console style representing the TextMate theme. - public static Style ToSpectreStyle(this Theme theme) - { - return new Style(foreground: Color.Default, background: Color.Default); - } + public static Style ToSpectreStyle(this Theme theme) => new(foreground: Color.Default, background: Color.Default); /// /// Converts a TextMate color to a Spectre.Console color. /// @@ -22,16 +22,12 @@ public static Style ToSpectreStyle(this Theme theme) /// A Spectre.Console color representing the TextMate color. // Try to use a more general color type, e.g. System.Drawing.Color or a custom struct/class // If theme.Foreground and theme.Background are strings (hex), parse them accordingly - public static Color ToSpectreColor(this object color) - { - if (color is string hex && !string.IsNullOrWhiteSpace(hex)) - { - try - { + public static Color ToSpectreColor(this object color) { + if (color is string hex && !string.IsNullOrWhiteSpace(hex)) { + try { return StyleHelper.HexToColor(hex); } - catch - { + catch { return Color.Default; } } @@ -43,8 +39,7 @@ public static Color ToSpectreColor(this object color) /// The TextMate font style to convert. /// A Spectre.Console font style representing the TextMate font style. - public static FontStyle ToSpectreFontStyle(this FontStyle fontStyle) - { + public static FontStyle ToSpectreFontStyle(this FontStyle fontStyle) { FontStyle result = FontStyle.None; if ((fontStyle & FontStyle.Italic) != 0) result |= FontStyle.Italic; diff --git a/src/Helpers/Completers.cs b/src/Helpers/Completers.cs index 790d849..f5ebe2c 100644 --- a/src/Helpers/Completers.cs +++ b/src/Helpers/Completers.cs @@ -8,8 +8,10 @@ namespace PwshSpectreConsole.TextMate; -public sealed class LanguageCompleter : IArgumentCompleter -{ +/// +/// Argument completer for TextMate language IDs and file extensions in PowerShell commands. +/// +public sealed class LanguageCompleter : IArgumentCompleter { /// /// Offers completion for both TextMate language ids and file extensions. /// Examples: "powershell", "csharp", ".md", "md", ".ps1", "ps1". @@ -19,22 +21,19 @@ public IEnumerable CompleteArgument( string parameterName, string wordToComplete, CommandAst commandAst, - IDictionary fakeBoundParameters) - { + IDictionary fakeBoundParameters) { string input = wordToComplete ?? string.Empty; bool wantsExtensionsOnly = input.Length > 0 && input[0] == '.'; // Prefer wildcard matching semantics; fall back to prefix/contains when empty WildcardPattern? pattern = null; - if (!string.IsNullOrEmpty(input)) - { + if (!string.IsNullOrEmpty(input)) { // Add trailing * if not already present to make incremental typing friendly string normalized = input[^1] == '*' ? input : input + "*"; pattern = new WildcardPattern(normalized, WildcardOptions.IgnoreCase); } - bool Match(string token) - { + bool Match(string token) { if (pattern is null) return true; // no filter if (pattern.IsMatch(token)) return true; // Also test without a leading dot to match bare extensions like "ps1" against ".ps1" @@ -44,11 +43,9 @@ bool Match(string token) // Build suggestions var results = new List(); - if (!wantsExtensionsOnly) - { + if (!wantsExtensionsOnly) { // Languages first - foreach (string lang in TextMateHelper.Languages ?? []) - { + foreach (string lang in TextMateHelper.Languages ?? []) { if (!Match(lang)) continue; results.Add(new CompletionResult( completionText: lang, @@ -59,8 +56,7 @@ bool Match(string token) } // Extensions (always include if requested or no leading '.') - foreach (string ext in TextMateHelper.Extensions ?? []) - { + foreach (string ext in TextMateHelper.Extensions ?? []) { if (!Match(ext)) continue; string completion = ext; // keep dot in completion string display = ext; @@ -79,43 +75,62 @@ bool Match(string token) .ThenBy(r => r.CompletionText, StringComparer.OrdinalIgnoreCase); } } -public class TextMateLanguages : IValidateSetValuesGenerator - { - public string[] GetValidValues() - { - return TextMateHelper.Languages; - } - public static bool IsSupportedLanguage(string language) - { - return TextMateHelper.Languages.Contains(language); - } - } -public class TextMateExtensions : IValidateSetValuesGenerator -{ - public string[] GetValidValues() - { - return TextMateHelper.Extensions; - } - public static bool IsSupportedExtension(string extension) - { - return TextMateHelper.Extensions?.Contains(extension) == true; - } - public static bool IsSupportedFile(string file) - { +/// +/// Provides validation for TextMate language IDs in parameter validation attributes. +/// +public class TextMateLanguages : IValidateSetValuesGenerator { + /// + /// Returns the list of all valid TextMate language IDs for parameter validation. + /// + /// Array of supported language identifiers + public string[] GetValidValues() => TextMateHelper.Languages; + /// + /// Checks if a language ID is supported by TextMate. + /// + /// Language ID to validate + /// True if the language is supported, false otherwise + public static bool IsSupportedLanguage(string language) => TextMateHelper.Languages.Contains(language); +} +/// +/// Provides validation for file extensions in parameter validation attributes. +/// +public class TextMateExtensions : IValidateSetValuesGenerator { + /// + /// Returns the list of all valid file extensions for parameter validation. + /// + /// Array of supported file extensions + public string[] GetValidValues() => TextMateHelper.Extensions; + /// + /// Checks if a file extension is supported by TextMate. + /// + /// File extension to validate (with or without dot) + /// True if the extension is supported, false otherwise + public static bool IsSupportedExtension(string extension) => TextMateHelper.Extensions?.Contains(extension) == true; + /// + /// Checks if a file has a supported extension. + /// + /// File path to check + /// True if the file has a supported extension, false otherwise + public static bool IsSupportedFile(string file) { string ext = Path.GetExtension(file); return TextMateHelper.Extensions?.Contains(ext) == true; } } -public class TextMateExtensionTransform : ArgumentTransformationAttribute -{ - public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) - { - if (inputData is string input) - { - return input.StartsWith('.') ? input : '.' + input; - } - throw new ArgumentException("Input must be a string representing a file extension., '.ext' format expected.", nameof(inputData)); +/// +/// Argument transformer that normalizes file extensions to include a leading dot. +/// +public class TextMateExtensionTransform : ArgumentTransformationAttribute { + /// + /// Transforms an extension to include a leading dot if missing. + /// + /// PowerShell engine intrinsics + /// Input string representing a file extension + /// Normalized extension with leading dot + public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) { + return inputData is string input + ? (object)(input.StartsWith('.') ? input : '.' + input) + : throw new ArgumentException("Input must be a string representing a file extension., '.ext' format expected.", nameof(inputData)); } } diff --git a/src/Helpers/Debug.cs b/src/Helpers/Debug.cs index 3ab0553..3b7cdc1 100644 --- a/src/Helpers/Debug.cs +++ b/src/Helpers/Debug.cs @@ -1,39 +1,79 @@  +using System.Collections.ObjectModel; +using Spectre.Console; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using Spectre.Console; -using System.Collections.ObjectModel; // this is just for debugging purposes. namespace PwshSpectreConsole.TextMate; -public static class Test -{ - public class TextMateDebug - { +/// +/// Provides debugging utilities for TextMate processing operations. +/// +public static class Test { + /// + /// Debug information wrapper for TextMate token and styling data. + /// + public class TextMateDebug { + /// + /// Line number of the token (zero-based). + /// public int? LineIndex { get; set; } + /// + /// Starting character position of the token. + /// public int StartIndex { get; set; } + /// + /// Ending character position of the token. + /// public int EndIndex { get; set; } + /// + /// Text content of the token. + /// public string? Text { get; set; } + /// + /// Scopes applying to this token for theme matching. + /// public List? Scopes { get; set; } + /// + /// Foreground color ID from the theme. + /// public int Foreground { get; set; } + /// + /// Background color ID from the theme. + /// public int Background { get; set; } + /// + /// Font style flags (bold, italic, underline). + /// public FontStyle FontStyle { get; set; } + /// + /// Resolved Spectre.Console style for rendering. + /// public Style? Style { get; set; } + /// + /// Theme color dictionary used for rendering. + /// public ReadOnlyDictionary? Theme { get; set; } } - public static TextMateDebug[]? DebugTextMate(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) - { + /// + /// Debugs TextMate processing and returns styled token information. + /// + /// Text lines to debug + /// Theme to apply + /// Grammar language ID or file extension + /// True if grammarId is a file extension, false for language ID + /// Array of debug information for all tokens + public static TextMateDebug[]? DebugTextMate(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) { var debugList = new List(); - PwshSpectreConsole.TextMate.Core.TextMateProcessor.ProcessLines( + Core.TextMateProcessor.ProcessLines( lines, themeName, grammarId, isExtension: FromFile, - debugCallback: info => debugList.Add(new TextMateDebug - { + debugCallback: info => debugList.Add(new TextMateDebug { LineIndex = info.LineIndex, StartIndex = info.StartIndex, EndIndex = info.EndIndex, @@ -46,19 +86,26 @@ public class TextMateDebug Theme = info.Theme }) ); - return debugList.ToArray(); + return [.. debugList]; } - public static Core.TokenDebugInfo[]? DebugTextMateTokens(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) - { + /// + /// Returns detailed debug information for each token with styling applied. + /// + /// Text lines to debug + /// Theme to apply + /// Grammar language ID or file extension + /// True if grammarId is a file extension, false for language ID + /// Array of token debug information + public static Core.TokenDebugInfo[]? DebugTextMateTokens(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) { var debugList = new List(); - PwshSpectreConsole.TextMate.Core.TextMateProcessor.ProcessLines( + Core.TextMateProcessor.ProcessLines( lines, themeName, grammarId, isExtension: FromFile, - debugCallback: info => debugList.Add(info) + debugCallback: debugList.Add ); - return debugList.ToArray(); + return [.. debugList]; } } diff --git a/src/Helpers/Helpers.cs b/src/Helpers/Helpers.cs index 9b4c51f..5f269e3 100644 --- a/src/Helpers/Helpers.cs +++ b/src/Helpers/Helpers.cs @@ -2,15 +2,24 @@ namespace PwshSpectreConsole.TextMate; -public static class TextMateHelper -{ +/// +/// Provides utility methods for accessing available TextMate languages and file extensions. +/// +public static class TextMateHelper { + /// + /// Array of supported file extensions (e.g., ".ps1", ".md", ".cs"). + /// public static readonly string[] Extensions; + /// + /// Array of supported TextMate language identifiers (e.g., "powershell", "markdown", "csharp"). + /// public static readonly string[] Languages; + /// + /// List of all available language definitions with metadata. + /// public static readonly List AvailableLanguages; - static TextMateHelper() - { - try - { + static TextMateHelper() { + try { RegistryOptions _registryOptions = new(ThemeName.DarkPlus); AvailableLanguages = _registryOptions.GetAvailableLanguages(); @@ -23,8 +32,7 @@ static TextMateHelper() .Where(x => x.Id is not null) .Select(x => x.Id)]; } - catch (Exception ex) - { + catch (Exception ex) { throw new TypeInitializationException(nameof(TextMateHelper), ex); } } diff --git a/src/Helpers/ImageFile.cs b/src/Helpers/ImageFile.cs index eb37637..ca234dc 100644 --- a/src/Helpers/ImageFile.cs +++ b/src/Helpers/ImageFile.cs @@ -13,52 +13,43 @@ namespace PwshSpectreConsole.TextMate.Helpers; /// /// Normalizes various image sources (file paths, URLs, base64) into file paths that can be used by Spectre.Console.SixelImage. /// -internal static class ImageFile -{ +internal static partial class ImageFile { private static readonly HttpClient HttpClient = new(); - private static readonly Regex Base64Regex = new(@"^data:image\/(?[a-zA-Z]+);base64,(?[A-Za-z0-9+/=]+)$", RegexOptions.Compiled); + + [GeneratedRegex(@"^data:image\/(?[a-zA-Z]+);base64,(?[A-Za-z0-9+/=]+)$", RegexOptions.Compiled)] + private static partial Regex Base64Regex(); /// /// Normalizes an image source to a local file path that can be used by SixelImage. /// /// The image source (file path, URL, or base64 data URI) /// A local file path, or null if the image cannot be processed - public static async Task NormalizeImageSourceAsync(string imageSource) - { - if (string.IsNullOrWhiteSpace(imageSource)) - { + public static async Task NormalizeImageSourceAsync(string imageSource) { + if (string.IsNullOrWhiteSpace(imageSource)) { return null; } // Check if it's a base64 data URI - Match base64Match = Base64Regex.Match(imageSource); - if (base64Match.Success) - { + Match base64Match = Base64Regex().Match(imageSource); + if (base64Match.Success) { return await ConvertBase64ToFileAsync(base64Match.Groups["type"].Value, base64Match.Groups["data"].Value); } // Check if it's a URL if (Uri.TryCreate(imageSource, UriKind.Absolute, out Uri? uri) && - (uri.Scheme == "http" || uri.Scheme == "https")) - { + (uri.Scheme == "http" || uri.Scheme == "https")) { return await DownloadImageToTempFileAsync(uri); } // Check if it's a local file path - if (File.Exists(imageSource)) - { + if (File.Exists(imageSource)) { return imageSource; } // Try to resolve relative paths string currentDirectory = Environment.CurrentDirectory; string fullPath = Path.GetFullPath(Path.Combine(currentDirectory, imageSource)); - if (File.Exists(fullPath)) - { - return fullPath; - } - - return null; + return File.Exists(fullPath) ? fullPath : null; } /// @@ -67,35 +58,28 @@ internal static class ImageFile /// The image type (e.g., "png", "jpg") /// The base64 encoded image data /// Path to the temporary file, or null if conversion fails - private static async Task ConvertBase64ToFileAsync(string imageType, string base64Data) - { - try - { + private static async Task ConvertBase64ToFileAsync(string imageType, string base64Data) { + try { byte[] imageBytes = Convert.FromBase64String(base64Data); string tempFileName = Path.Combine(Path.GetTempPath(), $"pstextmate_img_{Guid.NewGuid():N}.{imageType}"); await File.WriteAllBytesAsync(tempFileName, imageBytes); // Schedule cleanup after a reasonable time (1 hour) - _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => - { - try - { - if (File.Exists(tempFileName)) - { + _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => { + try { + if (File.Exists(tempFileName)) { File.Delete(tempFileName); } } - catch - { + catch { // Ignore cleanup errors } }); return tempFileName; } - catch - { + catch { return null; } } @@ -105,13 +89,10 @@ internal static class ImageFile /// /// The image URL /// Path to the temporary file, or null if download fails - private static async Task DownloadImageToTempFileAsync(Uri imageUri) - { - try - { + private static async Task DownloadImageToTempFileAsync(Uri imageUri) { + try { using HttpResponseMessage response = await HttpClient.GetAsync(imageUri); - if (!response.IsSuccessStatusCode) - { + if (!response.IsSuccessStatusCode) { return null; } @@ -126,25 +107,20 @@ internal static class ImageFile await response.Content.CopyToAsync(fileStream); // Schedule cleanup after a reasonable time (1 hour) - _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => - { - try - { - if (File.Exists(tempFileName)) - { + _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => { + try { + if (File.Exists(tempFileName)) { File.Delete(tempFileName); } } - catch - { + catch { // Ignore cleanup errors } }); return tempFileName; } - catch - { + catch { return null; } } @@ -154,10 +130,8 @@ internal static class ImageFile /// /// The MIME content type /// The appropriate file extension - private static string? GetExtensionFromContentType(string? contentType) - { - return contentType?.ToLowerInvariant() switch - { + private static string? GetExtensionFromContentType(string? contentType) { + return contentType?.ToLowerInvariant() switch { "image/jpeg" => ".jpg", "image/jpg" => ".jpg", "image/png" => ".png", @@ -175,35 +149,30 @@ internal static class ImageFile /// /// The image source to check /// True if the image source is likely supported - public static bool IsLikelySupportedImageFormat(string imageSource) - { - if (string.IsNullOrWhiteSpace(imageSource)) - { + public static bool IsLikelySupportedImageFormat(string imageSource) { + if (string.IsNullOrWhiteSpace(imageSource)) { return false; } // Check for supported extensions string extension = Path.GetExtension(imageSource).ToLowerInvariant(); - string[] supportedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + string[] supportedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]; - if (supportedExtensions.Contains(extension)) - { + if (supportedExtensions.Contains(extension)) { return true; } // Check for base64 data URI with supported format - Match base64Match = Base64Regex.Match(imageSource); - if (base64Match.Success) - { + Match base64Match = Base64Regex().Match(imageSource); + if (base64Match.Success) { string imageType = base64Match.Groups["type"].Value.ToLowerInvariant(); - string[] supportedTypes = new[] { "jpg", "jpeg", "png", "gif", "bmp", "webp" }; + string[] supportedTypes = ["jpg", "jpeg", "png", "gif", "bmp", "webp"]; return supportedTypes.Contains(imageType); } // For URLs, check the extension in the URL path if (Uri.TryCreate(imageSource, UriKind.Absolute, out Uri? uri) && - (uri.Scheme == "http" || uri.Scheme == "https")) - { + (uri.Scheme == "http" || uri.Scheme == "https")) { string urlExtension = Path.GetExtension(uri.LocalPath).ToLowerInvariant(); return supportedExtensions.Contains(urlExtension); } diff --git a/src/Helpers/StringBuilderPool.cs b/src/Helpers/StringBuilderPool.cs index 3893528..3eb8505 100644 --- a/src/Helpers/StringBuilderPool.cs +++ b/src/Helpers/StringBuilderPool.cs @@ -3,18 +3,12 @@ namespace PwshSpectreConsole.TextMate.Helpers; -internal static class StringBuilderPool -{ +internal static class StringBuilderPool { private static readonly ConcurrentBag _bag = []; - public static StringBuilder Rent() - { - if (_bag.TryTake(out StringBuilder? sb)) return sb; - return new StringBuilder(); - } + public static StringBuilder Rent() => _bag.TryTake(out StringBuilder? sb) ? sb : new StringBuilder(); - public static void Return(StringBuilder sb) - { + public static void Return(StringBuilder sb) { if (sb is null) return; sb.Clear(); _bag.Add(sb); diff --git a/src/Helpers/TextMateResolver.cs b/src/Helpers/TextMateResolver.cs index 3a827e8..47d29cb 100644 --- a/src/Helpers/TextMateResolver.cs +++ b/src/Helpers/TextMateResolver.cs @@ -5,8 +5,7 @@ namespace PwshSpectreConsole.TextMate; /// /// Resolves a user-provided token into either a TextMate language id or a file extension. /// -internal static class TextMateResolver -{ +internal static class TextMateResolver { /// /// Resolve a grammar token that may be a language id or a file extension. /// Heuristics: @@ -14,21 +13,17 @@ internal static class TextMateResolver /// - If known TextMate language id, treat as language /// - Otherwise treat as extension (allow values like 'ps1', 'md') /// - public static (string token, bool asExtension) ResolveToken(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { + public static (string token, bool asExtension) ResolveToken(string value) { + if (string.IsNullOrWhiteSpace(value)) { return ("powershell", false); } string v = value.Trim(); - if (v.StartsWith('.')) - { + if (v.StartsWith('.')) { return (v, true); } - if (TextMateLanguages.IsSupportedLanguage(v)) - { + if (TextMateLanguages.IsSupportedLanguage(v)) { return (v, false); } diff --git a/src/Helpers/VTConversion.cs b/src/Helpers/VTConversion.cs index 863ace8..524b1d4 100644 --- a/src/Helpers/VTConversion.cs +++ b/src/Helpers/VTConversion.cs @@ -7,8 +7,7 @@ namespace PwshSpectreConsole.TextMate.Core.Helpers; /// Efficient parser for VT (Virtual Terminal) escape sequences that converts them to Spectre.Console objects. /// Supports RGB colors, 256-color palette, 3-bit colors, and text decorations. /// -public static class VTParser -{ +public static class VTParser { private const char ESC = '\x1B'; private const char CSI_START = '['; private const char OSC_START = ']'; @@ -22,8 +21,7 @@ public static class VTParser /// /// Input string with VT escape sequences /// Paragraph object with parsed styles applied - public static Paragraph ToParagraph(string input) - { + public static Paragraph ToParagraph(string input) { if (string.IsNullOrEmpty(input)) return new Paragraph(); @@ -32,15 +30,12 @@ public static Paragraph ToParagraph(string input) return new Paragraph(input, Style.Plain); var paragraph = new Paragraph(); - foreach (TextSegment segment in segments) - { - if (segment.HasStyle) - { + foreach (TextSegment segment in segments) { + if (segment.HasStyle) { // Style class supports links directly via constructor parameter paragraph.Append(segment.Text, segment.Style.ToSpectreStyle()); } - else - { + else { paragraph.Append(segment.Text, Style.Plain); } } @@ -51,80 +46,65 @@ public static Paragraph ToParagraph(string input) /// /// Parses input string into styled text segments. /// - private static List ParseToSegments(string input) - { + private static List ParseToSegments(string input) { var segments = new List(); ReadOnlySpan span = input.AsSpan(); var currentStyle = new StyleState(); int textStart = 0; int i = 0; - while (i < span.Length) - { - if (span[i] == ESC && i + 1 < span.Length) - { - if (span[i + 1] == CSI_START) - { + while (i < span.Length) { + if (span[i] == ESC && i + 1 < span.Length) { + if (span[i + 1] == CSI_START) { // Add text segment before escape sequence - if (i > textStart) - { - string text = input.Substring(textStart, i - textStart); + if (i > textStart) { + string text = input[textStart..i]; segments.Add(new TextSegment(text, currentStyle.Clone())); } // Parse CSI escape sequence int escapeEnd = ParseEscapeSequence(span, i, ref currentStyle); - if (escapeEnd > i) - { + if (escapeEnd > i) { i = escapeEnd; textStart = i; } - else - { + else { i++; } } - else if (span[i + 1] == OSC_START) - { + else if (span[i + 1] == OSC_START) { // Add text segment before OSC sequence - if (i > textStart) - { - string text = input.Substring(textStart, i - textStart); + if (i > textStart) { + string text = input[textStart..i]; segments.Add(new TextSegment(text, currentStyle.Clone())); } // Parse OSC sequence OscResult oscResult = ParseOscSequence(span, i, ref currentStyle); - if (oscResult.End > i) - { + if (oscResult.End > i) { // If we found hyperlink text, add it as a segment - if (!string.IsNullOrEmpty(oscResult.LinkText)) - { + if (!string.IsNullOrEmpty(oscResult.LinkText)) { segments.Add(new TextSegment(oscResult.LinkText, currentStyle.Clone())); } i = oscResult.End; textStart = i; } - else - { + else { i++; } } - else - { + else { i++; } } - else - { + else { i++; } } // Add remaining text - if (textStart < span.Length) - { - string text = input.Substring(textStart); + if (textStart < span.Length) { + string text = input[textStart..]; segments.Add(new TextSegment(text, currentStyle.Clone())); } @@ -135,37 +115,31 @@ private static List ParseToSegments(string input) /// Parses a single VT escape sequence and updates the style state. /// Returns the index after the escape sequence. /// - private static int ParseEscapeSequence(ReadOnlySpan span, int start, ref StyleState style) - { + private static int ParseEscapeSequence(ReadOnlySpan span, int start, ref StyleState style) { int i = start + 2; // Skip ESC[ var parameters = new List(); int currentNumber = 0; bool hasNumber = false; // Parse parameters (numbers separated by semicolons) - while (i < span.Length && span[i] != SGR_END) - { - if (IsDigit(span[i])) - { - currentNumber = currentNumber * 10 + (span[i] - '0'); + while (i < span.Length && span[i] != SGR_END) { + if (IsDigit(span[i])) { + currentNumber = (currentNumber * 10) + (span[i] - '0'); hasNumber = true; } - else if (span[i] == ';') - { + else if (span[i] == ';') { parameters.Add(hasNumber ? currentNumber : 0); currentNumber = 0; hasNumber = false; } - else - { + else { // Invalid character, abort parsing return start + 1; } i++; } - if (i >= span.Length || span[i] != SGR_END) - { + if (i >= span.Length || span[i] != SGR_END) { return start + 1; // Invalid sequence } @@ -181,64 +155,49 @@ private static int ParseEscapeSequence(ReadOnlySpan span, int start, ref S /// /// Result of parsing an OSC sequence. /// - private readonly struct OscResult - { - public readonly int End; - public readonly string? LinkText; - - public OscResult(int end, string? linkText = null) - { - End = end; - LinkText = linkText; - } + private readonly struct OscResult(int end, string? linkText = null) { + public readonly int End = end; + public readonly string? LinkText = linkText; } /// /// Parses an OSC (Operating System Command) sequence and updates the style state. /// Returns the result containing end position and any link text found. /// - private static OscResult ParseOscSequence(ReadOnlySpan span, int start, ref StyleState style) - { + private static OscResult ParseOscSequence(ReadOnlySpan span, int start, ref StyleState style) { int i = start + 2; // Skip ESC] // Check if this is OSC 8 (hyperlink) - if (i < span.Length && span[i] == '8' && i + 1 < span.Length && span[i + 1] == ';') - { + if (i < span.Length && span[i] == '8' && i + 1 < span.Length && span[i + 1] == ';') { i += 2; // Skip "8;" // Parse hyperlink sequence: ESC]8;params;url ESC\text ESC]8;; ESC\ int urlEnd = -1; // Find the semicolon that separates params from URL - while (i < span.Length && span[i] != ';') - { + while (i < span.Length && span[i] != ';') { i++; } - if (i < span.Length && span[i] == ';') - { + if (i < span.Length && span[i] == ';') { i++; // Skip the semicolon int urlStart = i; // Find the end of the URL (look for ESC\) - while (i < span.Length - 1) - { - if (span[i] == ESC && span[i + 1] == '\\') - { + while (i < span.Length - 1) { + if (span[i] == ESC && span[i + 1] == '\\') { urlEnd = i; break; } i++; } - if (urlEnd > urlStart) - { - string url = span.Slice(urlStart, urlEnd - urlStart).ToString(); + if (urlEnd > urlStart) { + string url = span[urlStart..urlEnd].ToString(); i = urlEnd + 2; // Skip ESC\ // Check if this is a link start (has URL) or link end (empty) - if (!string.IsNullOrEmpty(url)) - { + if (!string.IsNullOrEmpty(url)) { // This is a link start - find the link text and end sequence int linkTextStart = i; int linkTextEnd = -1; @@ -249,23 +208,20 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re if (span[i] == ESC && span[i + 1] == OSC_START && span[i + 2] == '8' && span[i + 3] == ';' && span[i + 4] == ';' && span[i + 5] == ESC && - span[i + 6] == '\\') - { + span[i + 6] == '\\') { linkTextEnd = i; break; } i++; } - if (linkTextEnd > linkTextStart) - { - string linkText = span.Slice(linkTextStart, linkTextEnd - linkTextStart).ToString(); + if (linkTextEnd > linkTextStart) { + string linkText = span[linkTextStart..linkTextEnd].ToString(); style.Link = url; return new OscResult(linkTextEnd + 7, linkText); // Skip ESC]8;;ESC\ } } - else - { + else { // This is likely a link end sequence: ESC]8;;ESC\ style.Link = null; return new OscResult(i); @@ -275,10 +231,8 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re } // If we can't parse the OSC sequence, skip to the next ESC\ or end of string - while (i < span.Length - 1) - { - if (span[i] == ESC && span[i + 1] == '\\') - { + while (i < span.Length - 1) { + if (span[i] == ESC && span[i + 1] == '\\') { return new OscResult(i + 2); } i++; @@ -290,14 +244,11 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re /// /// Applies SGR (Select Graphic Rendition) parameters to the style state. /// - private static void ApplySgrParameters(List parameters, ref StyleState style) - { - for (int i = 0; i < parameters.Count; i++) - { + private static void ApplySgrParameters(List parameters, ref StyleState style) { + for (int i = 0; i < parameters.Count; i++) { int param = parameters[i]; - switch (param) - { + switch (param) { case 0: // Reset style.Reset(); break; @@ -353,8 +304,7 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl style.Foreground = GetConsoleColor(param); break; case 38: // Extended foreground color - if (i + 1 < parameters.Count) - { + if (i + 1 < parameters.Count) { int colorType = parameters[i + 1]; if (colorType == 2 && i + 4 < parameters.Count) // RGB { @@ -379,8 +329,7 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl style.Background = GetConsoleColor(param); break; case 48: // Extended background color - if (i + 1 < parameters.Count) - { + if (i + 1 < parameters.Count) { int colorType = parameters[i + 1]; if (colorType == 2 && i + 4 < parameters.Count) // RGB { @@ -407,6 +356,8 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl case >= 100 and <= 107: // High intensity 3-bit background colors style.Background = GetConsoleColor(param); break; + default: + break; } } } @@ -415,8 +366,7 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl /// Gets a Color object for standard console colors. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Color GetConsoleColor(int code) => code switch - { + private static Color GetConsoleColor(int code) => code switch { // 30 or 40 => Color.Black, // 31 or 41 => Color.Red, // 32 or 42 => Color.Green, @@ -474,16 +424,13 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl /// Gets a Color object for 256-color palette. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Color Get256Color(int index) - { - if (index < 0 || index > 255) + private static Color Get256Color(int index) { + if (index is < 0 or > 255) return Color.Default; // Standard 16 colors - if (index < 16) - { - return index switch - { + if (index < 16) { + return index switch { 0 => Color.Black, 1 => Color.Maroon, 2 => Color.Green, @@ -505,17 +452,16 @@ private static Color Get256Color(int index) } // 216 color cube (16-231) - if (index < 232) - { + if (index < 232) { int colorIndex = index - 16; - byte r = (byte)((colorIndex / 36) * 51); - byte g = (byte)(((colorIndex % 36) / 6) * 51); - byte b = (byte)((colorIndex % 6) * 51); + byte r = (byte)(colorIndex / 36 * 51); + byte g = (byte)(colorIndex % 36 / 6 * 51); + byte b = (byte)(colorIndex % 6 * 51); return new Color(r, g, b); } // Grayscale (232-255) - byte gray = (byte)((index - 232) * 10 + 8); + byte gray = (byte)(((index - 232) * 10) + 8); return new Color(gray, gray, gray); } @@ -528,25 +474,16 @@ private static Color Get256Color(int index) /// /// Represents a text segment with an associated style. /// - private readonly struct TextSegment - { - public readonly string Text; - public readonly StyleState Style; - public readonly bool HasStyle; - - public TextSegment(string text, StyleState style) - { - Text = text; - Style = style; - HasStyle = style.HasAnyStyle; - } + private readonly struct TextSegment(string text, StyleState style) { + public readonly string Text = text; + public readonly StyleState Style = style; + public readonly bool HasStyle = style.HasAnyStyle; } /// /// Represents the current style state during parsing. /// - private struct StyleState - { + private struct StyleState { public Color? Foreground; public Color? Background; public Decoration Decoration; @@ -554,37 +491,29 @@ private struct StyleState public readonly bool HasAnyStyle => Foreground.HasValue || Background.HasValue || Decoration != Decoration.None || !string.IsNullOrEmpty(Link); - public void Reset() - { + public void Reset() { Foreground = null; Background = null; Decoration = Decoration.None; Link = null; } - public readonly StyleState Clone() => new() - { + public readonly StyleState Clone() => new() { Foreground = Foreground, Background = Background, Decoration = Decoration, Link = Link }; - public readonly Style ToSpectreStyle() - { - return new Style(Foreground, Background, Decoration, Link); - } + public readonly Style ToSpectreStyle() => new(Foreground, Background, Decoration, Link); - public readonly string ToMarkup() - { + public readonly string ToMarkup() { var parts = new List(); - if (Foreground.HasValue) - { + if (Foreground.HasValue) { parts.Add(Foreground.Value.ToMarkup()); } - else - { + else { parts.Add("Default "); } @@ -592,8 +521,7 @@ public readonly string ToMarkup() if (Background.HasValue) parts.Add($"on {Background.Value.ToMarkup()}"); - if (Decoration != Decoration.None) - { + if (Decoration != Decoration.None) { if ((Decoration & Decoration.Bold) != 0) parts.Add("bold"); if ((Decoration & Decoration.Dim) != 0) parts.Add("dim"); if ((Decoration & Decoration.Italic) != 0) parts.Add("italic"); @@ -605,8 +533,7 @@ public readonly string ToMarkup() if ((Decoration & Decoration.Conceal) != 0) parts.Add("conceal"); } - if (!string.IsNullOrEmpty(Link)) - { + if (!string.IsNullOrEmpty(Link)) { parts.Add($"link={Link}"); } diff --git a/src/Infrastructure/CacheManager.cs b/src/Infrastructure/CacheManager.cs index c27b9cf..691e59f 100644 --- a/src/Infrastructure/CacheManager.cs +++ b/src/Infrastructure/CacheManager.cs @@ -9,8 +9,7 @@ namespace PwshSpectreConsole.TextMate.Infrastructure; /// Manages caching of expensive TextMate objects for improved performance. /// Uses thread-safe collections to handle concurrent access patterns. /// -internal static class CacheManager -{ +internal static class CacheManager { private static readonly ConcurrentDictionary _themeCache = new(); private static readonly ConcurrentDictionary _grammarCache = new(); @@ -20,10 +19,8 @@ internal static class CacheManager /// /// The theme to load /// Cached registry and theme pair - public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeName) - { - return _themeCache.GetOrAdd(themeName, name => - { + public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeName) { + return _themeCache.GetOrAdd(themeName, name => { RegistryOptions options = new(name); Registry registry = new(options); Theme theme = registry.GetTheme(); @@ -39,11 +36,9 @@ public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeNam /// Language ID or file extension /// True if grammarId is a file extension, false if it's a language ID /// Cached grammar instance or null if not found - public static IGrammar? GetCachedGrammar(Registry registry, string grammarId, bool isExtension) - { + public static IGrammar? GetCachedGrammar(Registry registry, string grammarId, bool isExtension) { string cacheKey = $"{grammarId}_{isExtension}"; - return _grammarCache.GetOrAdd(cacheKey, _ => - { + return _grammarCache.GetOrAdd(cacheKey, _ => { RegistryOptions options = new(ThemeName.Dark); // Use default for grammar loading return isExtension ? registry.LoadGrammar(options.GetScopeByExtension(grammarId)) @@ -54,8 +49,7 @@ public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeNam /// /// Clears all cached objects. Useful for memory management or when themes/grammars change. /// - public static void ClearCache() - { + public static void ClearCache() { _themeCache.Clear(); _grammarCache.Clear(); } diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index de41f45..3987e02 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -13,18 +13,18 @@ - - - - + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/demo-textmate.ps1 b/tests/demo-textmate.ps1 new file mode 100644 index 0000000..37e168d --- /dev/null +++ b/tests/demo-textmate.ps1 @@ -0,0 +1,123 @@ +#Requires -Modules 'PwshSpectreConsole' + +Set-SpectreColors -AccentColor BlueViolet + +# Build root layout scaffolding for: +# .--------------------------------. +# | Header | +# |--------------------------------| +# | File List | Preview | +# | | | +# | | | +# |___________|____________________| +$layout = New-SpectreLayout -Name "root" -Rows @( + # Row 1 + ( + New-SpectreLayout -Name "header" -MinimumSize 5 -Ratio 1 -Data ("empty") + ), + # Row 2 + ( + New-SpectreLayout -Name "content" -Ratio 10 -Columns @( + ( + New-SpectreLayout -Name "filelist" -Ratio 2 -Data "empty" + ), + ( + New-SpectreLayout -Name "preview" -Ratio 4 -Data "empty" + ) + ) + ) +) + +# Functions for rendering the content of each panel +function Get-TitlePanel { + return "📁 File Browser - Spectre Live Demo [gray]$(Get-Date)[/]" | Format-SpectreAligned -HorizontalAlignment Center -VerticalAlignment Middle | Format-SpectrePanel -Expand +} + +function Get-FileListPanel { + param ( + $Files, + $SelectedFile + ) + $fileList = $Files | ForEach-Object { + $name = $_.Name + if ($_.Name -eq $SelectedFile.Name) { + $name = "[darkviolet]$($name)[/]" + } + if ($_ -is [System.IO.DirectoryInfo]) { + "📁 $name" + } elseif ($_.Name -eq "..") { + "🔼 $name" + } else { + "📄 $name" + } + } | Out-String + return Format-SpectrePanel -Header "[white]File List[/]" -Data $fileList.Trim() -Expand +} + +function Get-PreviewPanel { + param ( + $SelectedFile + ) + $item = Get-Item -Path $SelectedFile.FullName + $result = "" + if ($item -is [System.IO.DirectoryInfo]) { + $result = "[grey]$($SelectedFile.Name) is a directory.[/]" + } else { + try { + # $content = Get-Content -Path $item.FullName -Raw -ErrorAction Stop + # $result = "[grey]$($content | Get-SpectreEscapedText)[/]" + $result = Show-TextMate -Path $item.FullName + } catch { + $result = "[red]Error reading file content: $($_.Exception.Message | Get-SpectreEscapedText)[/]" + } + } + return $result | Format-SpectrePanel -Header "[white]Preview[/]" -Expand +} + +# Start live rendering the layout +Invoke-SpectreLive -Data $layout -ScriptBlock { + param ( + $Context + ) + + # State + $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem) + $selectedFile = $fileList[0] + + while ($true) { + # Handle input + $lastKeyPressed = $null + while ([Console]::KeyAvailable) { + $lastKeyPressed = [Console]::ReadKey($true) + } + if ($lastKeyPressed -ne $null) { + if ($lastKeyPressed.Key -eq "DownArrow") { + $selectedFile = $fileList[($fileList.IndexOf($selectedFile) + 1) % $fileList.Count] + } elseif ($lastKeyPressed.Key -eq "UpArrow") { + $selectedFile = $fileList[($fileList.IndexOf($selectedFile) - 1 + $fileList.Count) % $fileList.Count] + } elseif ($lastKeyPressed.Key -eq "Enter") { + if ($selectedFile -is [System.IO.DirectoryInfo] -or $selectedFile.Name -eq "..") { + $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem -Path $selectedFile.FullName) + $selectedFile = $fileList[0] + } else { + notepad $selectedFile.FullName + return + } + } + } + + # Generate new data + $titlePanel = Get-TitlePanel + $fileListPanel = Get-FileListPanel -Files $fileList -SelectedFile $selectedFile + $previewPanel = Get-PreviewPanel -SelectedFile $selectedFile + + # Update layout + $layout["header"].Update($titlePanel) | Out-Null + $layout["filelist"].Update($fileListPanel) | Out-Null + $layout["preview"].Update($previewPanel) | Out-Null + + # Draw changes + $Context.Refresh() + Start-Sleep -Milliseconds 200 + } +} diff --git a/tests/test-markdown-rendering.md b/tests/test-markdown-rendering.md index 9d4ab70..a57021a 100644 --- a/tests/test-markdown-rendering.md +++ b/tests/test-markdown-rendering.md @@ -1,7 +1,5 @@ # Markdown Rendering Test File -## Code Blocks Test - ### Fenced Code Block with Language ```csharp @@ -37,19 +35,10 @@ and multiple lines - [x] Another completed task - [ ] Another incomplete task -## Headers - -# H1 Header -## H2 Header -### H3 Header -#### H4 Header -##### H5 Header -###### H6 Header - ## Paragraphs and Emphasis -This is a **bold** text and this is *italic* text. -Here's some `inline code` in a paragraph. +This is a **bold** text and this is *italic* text. +Here's some `inline code` in a paragraph. ## Tables @@ -60,17 +49,17 @@ Here's some `inline code` in a paragraph. ## Mixed Content -This paragraph contains **bold**, *italic*, and `code` elements all together. +This paragraph contains **bold**, *italic*, and `code` elements all together. ### Indented Code Block - This is an indented code block - with multiple lines - and preserved spacing + This is an indented code block + with multiple lines + and preserved spacing ## Special Characters and VT Sequences -Text with potential VT sequences: `\x1b[31mRed Text\x1b[0m` +Text with potential VT sequences: `\x1b[31mRed Text\x1b[0m` ## Edge Cases From 4b8b5f16bd4b68c221d2b602b35a0411f0448253 Mon Sep 17 00:00:00 2001 From: trackd Date: Thu, 15 Jan 2026 01:22:10 +0100 Subject: [PATCH 07/25] =?UTF-8?q?chore:=20=E2=9C=A8=20Update=20dependencie?= =?UTF-8?q?s=20and=20refactor=20tests=20for=20improved=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated `Onigwrap` package version to `1.0.*` for compatibility. * Changed `TextMateSharp` package version to `2.0.*` in both project files. * Refactored tests in `MarkdownRendererTests`, `StandardRendererTests`, and `TextMateProcessorTests` to directly assert on the result instead of accessing `Renderables`. * Simplified batch processing in `TextMateProcessorTests` to improve readability. * Added new `HighlightedText` class to encapsulate rendering logic and improve type safety. * Introduced `ImageBlockRenderer` for handling image rendering with various layouts. * Removed unnecessary `Core.Rows` wrapper to streamline rendering with `Spectre.Console.Rows`. --- .github/agents/csharp-dotnet-trackd.agent.md | 117 ++++++ Copilot-Processing.md | 344 ++++++++++++++++++ src/Cmdlets/ShowTextMateCmdlet.cs | 167 ++++----- src/Compatibility/Converter.cs | 7 +- src/Core/HighlightedText.cs | 93 +++++ src/Core/Markdown/MarkdownRenderer.cs | 6 +- src/Core/Markdown/Renderers/BlockRenderer.cs | 88 +++++ .../Markdown/Renderers/CodeBlockRenderer.cs | 8 +- .../Markdown/Renderers/HtmlBlockRenderer.cs | 7 +- .../Markdown/Renderers/ImageBlockRenderer.cs | 190 ++++++++++ src/Core/Markdown/Renderers/ImageRenderer.cs | 65 ++-- src/Core/Markdown/Renderers/ListRenderer.cs | 4 +- .../Markdown/Renderers/ParagraphRenderer.cs | 35 ++ src/Core/MarkdownRenderer.cs | 3 +- src/Core/RenderableBatch.cs | 59 --- src/Core/Rows.cs | 13 - src/Core/StandardRenderer.cs | 6 +- src/Core/TextMateProcessor.cs | 48 ++- src/Helpers/ImageFile.cs | 12 +- src/Helpers/VTConversion.cs | 226 +++++++----- src/PSTextMate.csproj | 2 +- tests/Core/Markdown/MarkdownRendererTests.cs | 8 +- tests/Core/StandardRendererTests.cs | 10 +- tests/Core/TextMateProcessorTests.cs | 23 +- tests/Integration/TaskListIntegrationTests.cs | 21 +- tests/PSTextMate.Tests.csproj | 2 +- 26 files changed, 1213 insertions(+), 351 deletions(-) create mode 100644 .github/agents/csharp-dotnet-trackd.agent.md create mode 100644 Copilot-Processing.md create mode 100644 src/Core/HighlightedText.cs create mode 100644 src/Core/Markdown/Renderers/ImageBlockRenderer.cs delete mode 100644 src/Core/RenderableBatch.cs delete mode 100644 src/Core/Rows.cs diff --git a/.github/agents/csharp-dotnet-trackd.agent.md b/.github/agents/csharp-dotnet-trackd.agent.md new file mode 100644 index 0000000..95d262c --- /dev/null +++ b/.github/agents/csharp-dotnet-trackd.agent.md @@ -0,0 +1,117 @@ +--- +description: 'C#/.NET Expert (trackd)' +tools: ['search', 'vscode', 'edit', 'execute','read', 'search', 'web', 'execute', 'awesome-copilot/*', 'todo'] + + +--- +# C#/.NET Expert (trackd) + +You are in expert software engineer mode. Your task is to provide expert software engineering +guidance using modern software design patterns as if you were a leader in the field. + +You will provide: + +- insights, best practices and recommendations for .NET software engineering as if you were Anders + Hejlsberg, the original architect of C# and a key figure in the development of .NET as well as + Mads Torgersen, the lead designer of C#. +- general software engineering guidance and best-practices, clean code and modern software design, + as if you were Robert C. Martin (Uncle Bob), a renowned software engineer and author of "Clean + Code" and "The Clean Coder". +- DevOps and CI/CD best practices, as if you were Jez Humble, co-author of "Continuous Delivery" and + "The DevOps Handbook". +- Testing and test automation best practices, as if you were Kent Beck, the creator of Extreme + Programming (XP) and a pioneer in Test-Driven Development (TDD). +- Perform janitorial tasks on C#/.NET codebases. Focus on code cleanup, modernization, and technical + debt remediation. +- The only way to unload a powershell binary module is to restart the pwsh.exe that imported the dll, otherwise the build will fail cause the file is locked. typically you get around this buy running commands in a new pwsh.exe, like 'pwsh.exe -noprofile -command { }' +or Example: pwsh -NoProfile -File .\build.ps1 -Task Test -FullNameFilter 'Record type support' + +For .NET-specific guidance, focus on the following areas: + +- **Design Patterns**: Use and explain modern design patterns such as Async/Await, Dependency + Injection, Repository Pattern, Unit of Work, CQRS, Event Sourcing and of course the Gang of Four + patterns. +- **SOLID Principles**: Emphasize the importance of SOLID principles in software design, ensuring + that code is maintainable, scalable, and testable. +- **Testing**: Advocate for Test-Driven Development (TDD) and Behavior-Driven Development (BDD) + practices, using frameworks like xUnit, NUnit, or MSTest. +- **Performance**: Provide insights on performance optimization techniques, including memory + management, asynchronous programming, and efficient data access patterns. +- **Security**: Highlight best practices for securing .NET applications, including authentication, + authorization, and data protection. + +## Core Tasks + +### Code Modernization + +- Update to latest C# language features and syntax patterns +- Replace obsolete APIs with modern alternatives +- Convert to nullable reference types where appropriate +- Apply pattern matching and switch expressions +- Use collection expressions and primary constructors + +### Code Quality + +- Remove unused usings, variables, and members +- Fix naming convention violations (PascalCase, camelCase) +- Simplify LINQ expressions and method chains +- Apply consistent formatting and indentation +- Resolve compiler warnings and static analysis issues + +### Performance Optimization + +- Replace inefficient collection operations +- Use `StringBuilder` for string concatenation +- Apply `async`/`await` patterns correctly +- Optimize memory allocations and boxing +- Use `Span` and `Memory` where beneficial + +### Test Coverage + +- Identify missing test coverage +- Add unit tests for public APIs +- Create integration tests for critical workflows +- Apply AAA (Arrange, Act, Assert) pattern consistently +- Use FluentAssertions for readable assertions + +### Documentation + +- Add XML documentation comments +- Update README files and inline comments +- Document public APIs and complex algorithms +- Add code examples for usage patterns + +## Documentation Resources + +Use `microsoft.docs.mcp` tool to: + +- Look up current .NET best practices and patterns +- Find official Microsoft documentation for APIs +- Verify modern syntax and recommended approaches +- Research performance optimization techniques +- Check migration guides for deprecated features + +Query examples: + +- "C# nullable reference types best practices" +- ".NET performance optimization patterns" +- "async await guidelines C#" +- "LINQ performance considerations" + +## Execution Rules + +1. **Validate Changes**: Run tests after each modification +2. **Incremental Updates**: Make small, focused changes +3. **Preserve Behavior**: Maintain existing functionality +4. **Follow Conventions**: Apply consistent coding standards +5. **Safety First**: Backup before major refactoring + +## Analysis Order + +1. Scan for compiler warnings and errors +2. Identify deprecated/obsolete usage +3. Check test coverage gaps +4. Review performance bottlenecks +5. Assess documentation completeness + +Apply changes systematically, testing after each modification. diff --git a/Copilot-Processing.md b/Copilot-Processing.md new file mode 100644 index 0000000..d429e51 --- /dev/null +++ b/Copilot-Processing.md @@ -0,0 +1,344 @@ +# Copilot Processing Log + +## User Request + +Fix compilation errors caused by namespace conflicts between `Core.Rows` and `Spectre.Console.Rows`. Three errors: + +1. `HighlightedText.cs(18,34)`: 'Rows' does not contain a definition for 'Count' +2. `ShowTextMateCmdlet.cs(147,27)`: Cannot convert Spectre.Console.Rows to Core.Rows +3. `ShowTextMateCmdlet.cs(184,31)`: Cannot convert Spectre.Console.Rows to Core.Rows + +## Analysis Complete + +### Current Architecture + +- **Core.Rows** - Internal record type with: + - `IRenderable[] Renderables` property + - `Count` property (implements IReadOnlyList) + - Simple wrapper around array + +- **Spectre.Console.Rows** - External library type: + - Constructor: `new Spectre.Console.Rows(IRenderable[])` + - Appears to be collection type (has Count) + - Used for final rendering output + +### Root Cause + +Claude attempted to make `HighlightedText` use `Spectre.Console.Rows` directly, but: + +1. Changed property type without verifying Spectre.Console.Rows has Count +2. Cmdlet still tries to create `Spectre.Console.Rows` from `Core.Rows` return values +3. Missing conversion from `Core.Rows` to `Spectre.Console.Rows` + +### Solution Strategy + +Keep internal/external separation clean: + +- Internal processing: Uses `Core.Rows` (lightweight, efficient) +- Public API boundary: Convert to `Spectre.Console.Rows` via `Converter.ProcessLines()` +- `HighlightedText`: Store `Core.Rows` internally, expose count directly +- Conversion happens at API boundary, not in domain types + +## Action Plan + +### Phase 1: Fix HighlightedText ✅ + +- [x] Change `HighlightedText.Rows` back to `Core.Rows` type +- [x] Fix `LineCount` property to use `Core.Rows.Count` +- [x] Update `ToPanel()` and `WithPadding()` to convert to Spectre.Console.Rows + +### Phase 2: Fix ShowTextMateCmdlet ✅ + +- [x] Update `ProcessStringInput()` to return Core.Rows directly (no conversion) +- [x] Update `ProcessPathInput()` to return Core.Rows directly (no conversion) +- [x] Ensure HighlightedText wraps Core.Rows properly + +### Phase 3: Verify TextMateProcessor Compatibility ✅ + +- [x] Ensure ProcessLinesInBatches returns HighlightedText with Core.Rows +- [x] Check conversion points are correct + +### Phase 4: Build and Verify ✅ + +- [x] Build solution +- [x] Fix any remaining compilation errors +- [x] Verify architecture is clean + +## Summary + +### Changes Implemented + +1. **[HighlightedText.cs](src/Core/HighlightedText.cs)** + - Changed `Rows` property from `Spectre.Console.Rows` to `Core.Rows` + - `LineCount` now correctly uses `Core.Rows.Count` + - `ToPanel()` converts `Core.Rows.Renderables` to `Spectre.Console.Rows` at rendering time + - `WithPadding()` methods convert `Core.Rows.Renderables` to `Spectre.Console.Rows` at rendering time + +2. **[ShowTextMateCmdlet.cs](src/Cmdlets/ShowTextMateCmdlet.cs)** + - `ProcessStringInput()` now returns `HighlightedText` with `Core.Rows` directly + - `ProcessPathInput()` non-streaming case returns `HighlightedText` with `Core.Rows` directly + - Removed incorrect `Converter.ProcessLines()` calls and conversions + - Now uses `TextMateProcessor.ProcessLines()` directly which returns `Core.Rows` + +3. **[TextMateProcessor.cs](src/Core/TextMateProcessor.cs)** + - `ProcessLinesInBatches()` now creates `HighlightedText` with `Core.Rows` instead of converting to `Spectre.Console.Rows` + - Both batch and final batch processing now wrap `Core.Rows` directly + +### Architecture Verification + +✅ **Internal Processing Layer** + +- All processing methods return `Core.Rows` (lightweight wrapper) +- No unnecessary conversions during processing +- Efficient memory usage + +✅ **Public API Boundary** + +- `Converter.ProcessLines()` converts `Core.Rows` → `Spectre.Console.Rows` for consumers who need it +- `HighlightedText` stores `Core.Rows` internally +- Conversion to `Spectre.Console.Rows` happens only when rendering (ToPanel, WithPadding) + +✅ **Type Safety** + +- No namespace ambiguity +- Clear separation between internal and external types +- Proper conversions at boundaries + +### Build Results + +``` +Restore complete (0,5s) + PSTextMate net8.0 succeeded (0,8s) → src\bin\Debug\net8.0\PSTextMate.dll + +Build succeeded in 1,5s +``` + +**All compilation errors resolved. No errors or warnings.** + +## Status + +✅ **Complete** - All phases executed successfully. Solution builds without errors. + +--- + +## Architectural Analysis: Do We Need Core.Rows? + +### Current Reality + +**Core.Rows is just a thin wrapper:** +```csharp +public sealed record Rows(IRenderable[] Renderables) : IReadOnlyList { + public static Rows Empty { get; } = new Rows([]); + public int Count => Renderables.Length; + public IRenderable this[int index] => Renderables[index]; +} +``` + +**Spectre.Console.Rows does the same thing:** +- Takes `IRenderable[]` in constructor +- Implements collection interfaces +- Used for rendering multiple lines + +### The Only Real Difference + +**Dependency isolation** - `Core.Rows` means our Core layer doesn't reference Spectre.Console types directly. But we're already: +- Using `Spectre.Console.Rendering.IRenderable` everywhere +- Creating `Markup` and `Text` objects in renderers +- So this "isolation" is already broken + +### Usage Pattern Analysis + +**Current flow with conversion overhead:** +``` +StandardRenderer → new Core.Rows([.. rows]) + → stored in HighlightedText + → converted to new Spectre.Console.Rows(rows.Renderables) + → used in Panel/Padder +``` + +**Simpler flow if we used Spectre.Console.Rows directly:** +``` +StandardRenderer → new Spectre.Console.Rows([.. rows]) + → stored in HighlightedText + → used directly in Panel/Padder (no conversion!) +``` + +### Output Container Options Analysis + +| Container | Pros | Cons | Best For | +|-----------|------|------|----------| +| **Rows** | ✅ Lightweight
✅ Perfect for streaming
✅ Composable
✅ No rendering overhead | ❌ Plain (no decoration) | ✅ Streaming output
✅ Internal processing
✅ Building blocks | +| **Panel** | ✅ Professional look
✅ Built-in borders
✅ Titles/headers | ❌ Can't stream (single unit)
❌ Rendering overhead
❌ Not composable | Single-shot output
User can wrap Rows themselves | +| **Paragraphs** | ✅ Text wrapping | ❌ Not for code
❌ Loses formatting | ❌ Not suitable | +| **Layout** | ✅ Complex arrangements | ❌ Overkill
❌ Can't stream | ❌ Too complex | + +### Recommendation: **Eliminate Core.Rows** + +**Why:** +1. **Zero benefit** - It's just an alias for what Spectre.Console.Rows already does +2. **Performance cost** - Extra allocations converting between types +3. **Code complexity** - Namespace confusion, extra conversions +4. **Already coupled** - Core layer already uses Spectre.Console types + +**Keep Rows (not Panel) because:** +- ✅ **Streaming**: Can yield multiple Rows objects one at a time +- ✅ **Efficiency**: Minimal overhead, no border rendering +- ✅ **Composability**: Users can wrap in Panel/Layout/Grid as needed +- ✅ **Flexibility**: Let PowerShell users decide presentation + +**Migration:** +```csharp +// Before: src/Core/StandardRenderer.cs +public static Core.Rows Render(...) { + return new Rows([.. rows]); // Core.Rows +} + +// After: src/Core/StandardRenderer.cs +public static Spectre.Console.Rows Render(...) { + return new Spectre.Console.Rows([.. rows]); // Direct Spectre type +} +``` + +### Files to Update + +1. Delete `src/Core/Rows.cs` entirely +2. Update all return types: `Core.Rows` → `Spectre.Console.Rows` +3. Update `HighlightedText.Rows` property type +4. Remove conversion code in `ToPanel()` and `WithPadding()` +5. Update `Converter.cs` (might not need it anymore) +6. Simplify `TextMateProcessor.cs` batch processing + +**Benefit:** Simpler, faster, more idiomatic Spectre.Console usage. + +Would you like me to implement this refactoring? + +--- + +## Refactoring: Eliminate Core.Rows + +### Phase 1: Update Renderers ✅ +- [x] StandardRenderer.cs - Return Spectre.Console.Rows +- [x] MarkdownRenderer.cs - Return Spectre.Console.Rows +- [x] HtmlBlockRenderer.cs - Remove conversion code +- [x] CodeBlockRenderer.cs - Remove conversion code + +### Phase 2: Update Core Types ✅ +- [x] HighlightedText.cs - Use Spectre.Console.Rows, remove conversions +- [x] TextMateProcessor.cs - Return Spectre.Console.Rows + +### Phase 3: Update Public API ✅ +- [x] ShowTextMateCmdlet.cs - Use Spectre.Console.Rows +- [x] Converter.cs - Simplify (no conversion needed) + +### Phase 4: Cleanup ✅ +- [x] Delete Core/Rows.cs +- [x] Build and verify + +**DISCOVERY:** Spectre.Console.Rows lacks Count/Renderables properties! + +### Phase 5: Pivot to IRenderable[] ✅ +- [x] Change all return types to IRenderable[] +- [x] Update HighlightedText to store IRenderable[] +- [x] Create Spectre.Console.Rows only when rendering +- [x] Build and verify + +## Final Architecture + +**Core.Rows has been eliminated** - it was redundant because: +- Spectre.Console.Rows lacks Count/Renderables properties +- Core.Rows provided those, but we can use `IRenderable[]` directly + +**New clean architecture:** +``` +Internal processing → returns IRenderable[] +HighlightedText → stores IRenderable[] +Rendering methods → create Spectre.Console.Rows([.. array]) +Converter → wraps IRenderable[] in Spectre.Console.Rows for consumers +``` + +**Benefits:** +- ✅ No custom wrapper types +- ✅ Direct array access (Count = .Length) +- ✅ Spectre.Console.Rows created only when needed for rendering +- ✅ Simpler, more maintainable code +- ✅ Better performance (fewer allocations) + +Build: **SUCCESS** ✅ + +## Final Cleanup + +✅ Deleted [Core/Rows.cs](src/Core/Rows.cs) +✅ Updated all test files: +- [StandardRendererTests.cs](tests/Core/StandardRendererTests.cs) +- [TextMateProcessorTests.cs](tests/Core/TextMateProcessorTests.cs) +- [MarkdownRendererTests.cs](tests/Core/Markdown/MarkdownRendererTests.cs) +- [TaskListIntegrationTests.cs](tests/Integration/TaskListIntegrationTests.cs) + +**All compilation issues resolved.** + +### Test Changes +- Replaced `result.Renderables.Should()...` with `result.Should()...` +- Fixed batch indexing in tests to use `batchList[index]` instead of `batches[index]` +- Fixed `.Count` vs `.Count()` issues for lists +- Satisfied CA1806 code analysis warnings + +### Files Modified in This Session +1. src/Core/StandardRenderer.cs - Returns IRenderable[] +2. src/Core/MarkdownRenderer.cs (facade) - Returns IRenderable[] +3. src/Core/Markdown/MarkdownRenderer.cs - Returns IRenderable[] +4. src/Core/TextMateProcessor.cs - Returns IRenderable[] +5. src/Core/HighlightedText.cs - Stores IRenderable[], creates Spectre.Console.Rows on demand +6. src/Cmdlets/ShowTextMateCmdlet.cs - Uses IRenderable[] +7. src/Compatibility/Converter.cs - Wraps IRenderable[] in Spectre.Console.Rows +8. src/Core/Markdown/Renderers/HtmlBlockRenderer.cs - Returns IRenderable[] +9. src/Core/Markdown/Renderers/CodeBlockRenderer.cs - Returns IRenderable[] +10. Tests - Updated to use IRenderable[] directly + +**Total: Clean refactoring complete!** ✨ + +--- + +## Summary of Complete Refactoring + +### Problem +The codebase had unnecessary complexity with both: +- `Core.Rows` (internal wrapper) +- `Spectre.Console.Rows` (external library type, missing Count/Renderables properties) + +This created duplicate types and conversion overhead. + +### Solution +**Eliminate all custom Rows types and use `IRenderable[]` directly** + +### Architecture Before → After + +**Before:** +``` +Renderers → Core.Rows → HighlightedText.Rows (Core.Rows) + → convert to Spectre.Console.Rows → Panel/Padder +``` + +**After:** +``` +Renderers → IRenderable[] → HighlightedText.Renderables (IRenderable[]) + → create Spectre.Console.Rows only when rendering +``` + +### Benefits +✅ **Eliminated complexity** - Removed redundant Core.Rows type +✅ **Better performance** - No unnecessary conversions +✅ **Simpler API** - Direct array access instead of wrapper properties +✅ **More idiomatic** - Uses standard C# arrays instead of custom types +✅ **Easier testing** - Tests work directly with arrays + +### Test Results +- ✅ 19 compilation errors fixed +- ✅ All tests compile +- ✅ All tests pass +- ✅ No warnings (except 1 xUnit1026 unused parameter, pre-existing) + +### Files Deleted +- [Core/Rows.cs](src/Core/Rows.cs) - No longer needed + +### Key Learnings +**Always check library API before wrapping**: Spectre.Console.Rows lacked crucial properties (Count, Renderables), making our internal wrapper actually MORE useful. By recognizing this, we chose the simplest solution: use raw arrays instead of any Rows type. diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index c3d9ce2..c5f8c32 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -2,7 +2,7 @@ using PwshSpectreConsole.TextMate; using PwshSpectreConsole.TextMate.Core; using PwshSpectreConsole.TextMate.Extensions; -using Spectre.Console; +using Spectre.Console.Rendering; using TextMateSharp.Grammars; namespace PwshSpectreConsole.TextMate.Cmdlets; @@ -13,13 +13,9 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// [Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "String")] [Alias("st", "Show-Code")] -[OutputType(typeof(Spectre.Console.Rows), ParameterSetName = new[] { "String" })] -[OutputType(typeof(Spectre.Console.Rows), ParameterSetName = new[] { "Path" })] -[OutputType(typeof(RenderableBatch), ParameterSetName = new[] { "Path" })] +[OutputType(typeof(HighlightedText))] public sealed class ShowTextMateCmdlet : PSCmdlet { - private static readonly string[] NewLineSplit = ["\r\n", "\n", "\r"]; private readonly List _inputObjectBuffer = []; - private string? _sourceExtensionHint; /// /// String content to render with syntax highlighting. @@ -47,7 +43,7 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { /// /// TextMate language ID for syntax highlighting (e.g., 'powershell', 'csharp', 'python'). - /// If not specified, detected from file extension or content. + /// If not specified, detected from file extension (for files) or defaults to 'powershell' (for strings). /// [Parameter( ParameterSetName = "String" @@ -89,34 +85,17 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { /// protected override void ProcessRecord() { if (ParameterSetName == "String" && InputObject is not null) { - // Try to capture an extension hint from ETS note properties on the current pipeline object - // (e.g., PSChildName/PSPath added by Get-Content) - if (_sourceExtensionHint is null) { - if (GetVariableValue("_") is PSObject current) { - string? hint = current.Properties["PSChildName"]?.Value as string - ?? current.Properties["PSPath"]?.Value as string - ?? current.Properties["Path"]?.Value as string - ?? current.Properties["FullName"]?.Value as string; - if (!string.IsNullOrWhiteSpace(hint)) { - string ext = System.IO.Path.GetExtension(hint); - if (!string.IsNullOrWhiteSpace(ext)) { - _sourceExtensionHint = ext; - } - } - } - } + // Simply buffer all strings - no complex detection logic _inputObjectBuffer.Add(InputObject); return; } if (ParameterSetName == "Path" && !string.IsNullOrWhiteSpace(Path)) { try { - Spectre.Console.Rows? result = ProcessPathInput(); - if (result is not null) { - WriteObject(result); - if (PassThru) { - WriteVerbose($"Processed file '{Path}' with theme '{Theme}' {(string.IsNullOrWhiteSpace(Language) ? "(by extension)" : $"(token: {Language})")}"); - } + // Process file immediately when in Path parameter set + foreach (HighlightedText result in ProcessPathInput()) { + // Output each renderable directly so pwshspectreconsole can format them + WriteObject(result.Renderables, enumerateCollection: true); } } catch (Exception ex) { @@ -129,18 +108,18 @@ protected override void ProcessRecord() { /// Finalizes processing after all pipeline records have been processed. /// protected override void EndProcessing() { - // For Path parameter set, each record is processed in ProcessRecord to support streaming multiple files. - // Only finalize buffered String input here. + // Only process buffered strings in EndProcessing if (ParameterSetName != "String") { return; } try { - Spectre.Console.Rows? result = ProcessStringInput(); + HighlightedText? result = ProcessStringInput(); if (result is not null) { - WriteObject(result); + // Output each renderable directly so pwshspectreconsole can format them + WriteObject(result.Renderables, enumerateCollection: true); if (PassThru) { - WriteVerbose($"Processed {_inputObjectBuffer.Count} lines with theme '{Theme}' {(string.IsNullOrWhiteSpace(Language) ? "(by hint/default)" : $"(token: {Language})")}"); + WriteVerbose($"Processed {_inputObjectBuffer.Count} line(s) with theme '{Theme}' {GetLanguageDescription()}"); } } } @@ -149,78 +128,104 @@ protected override void EndProcessing() { } } - private Spectre.Console.Rows? ProcessStringInput() { + private HighlightedText? ProcessStringInput() { if (_inputObjectBuffer.Count == 0) { WriteVerbose("No input provided"); return null; } - string[] strings = [.. _inputObjectBuffer]; - // If only one string and it contains any newline, split it into lines for correct rendering - if (strings.Length == 1 && (strings[0].Contains('\n') || strings[0].Contains('\r'))) { - strings = strings[0].Split(NewLineSplit, StringSplitOptions.None); - } - if (strings.AllIsNullOrEmpty()) { + // Normalize buffered strings into lines + string[] lines = NormalizeToLines(_inputObjectBuffer); + + if (lines.AllIsNullOrEmpty()) { WriteVerbose("All input strings are null or empty"); return null; } - // If a Language token was provided, resolve it first (language id or extension) - if (!string.IsNullOrWhiteSpace(Language)) { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language); - return Converter.ProcessLines(strings, Theme, token, isExtension: asExtension); - } + // Resolve language (explicit parameter or default) + string effectiveLanguage = Language ?? "powershell"; + (string? token, bool asExtension) = TextMateResolver.ResolveToken(effectiveLanguage); - // Otherwise prefer extension hint from ETS (PSChildName/PSPath) - if (!string.IsNullOrWhiteSpace(_sourceExtensionHint)) { - return Converter.ProcessLines(strings, Theme, _sourceExtensionHint, isExtension: true); - } + // Process and wrap in HighlightedText + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension); - // Final fallback: default language - return Converter.ProcessLines(strings, Theme, "powershell", isExtension: false); + return renderables is null + ? null + : new HighlightedText { + Renderables = renderables + }; } - private Spectre.Console.Rows? ProcessPathInput() { + private IEnumerable ProcessPathInput() { FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(Path)); if (!filePath.Exists) { throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); } - // Decide how to interpret based on precedence: - // 1) Language token (can be a language id OR an extension) - // 2) File extension + // Set the base directory for relative image path resolution in markdown + // Use the full directory path or current directory if not available + string markdownBaseDir = filePath.DirectoryName ?? Environment.CurrentDirectory; + Core.Markdown.Renderers.ImageRenderer.CurrentMarkdownDirectory = markdownBaseDir; + WriteVerbose($"Set markdown base directory for image resolution: {markdownBaseDir}"); + + // Resolve language: explicit parameter > file extension + (string token, bool asExtension) = !string.IsNullOrWhiteSpace(Language) + ? TextMateResolver.ResolveToken(Language) + : (filePath.Extension, true); + if (Stream.IsPresent) { - // Stream file in batches - int batchIndex = 0; - if (!string.IsNullOrWhiteSpace(Language)) { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language); - WriteVerbose($"Streaming file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")}) in batches of {BatchSize}"); - foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension)) { - // Attach a stable batch index so consumers can track ordering - var indexed = new RenderableBatch(batch.Renderables, batchIndex: batchIndex++, fileOffset: batch.FileOffset); - WriteObject(indexed); - } - return null; + // Streaming mode - yield HighlightedText objects directly from processor + WriteVerbose($"Streaming file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}, batch size: {BatchSize}"); + + // Direct passthrough - processor returns HighlightedText now + foreach (HighlightedText result in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension)) { + yield return result; } + } + else { + // Single file processing + WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); + + string[] lines = File.ReadAllLines(filePath.FullName); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension); - string extension = filePath.Extension; - WriteVerbose($"Streaming file: {filePath.FullName} using file extension: {extension} in batches of {BatchSize}"); - foreach (RenderableBatch batch in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, extension, true)) { - var indexed = new RenderableBatch(batch.Renderables, batchIndex: batchIndex++, fileOffset: batch.FileOffset); - WriteObject(indexed); + if (renderables is not null) { + yield return new HighlightedText { + Renderables = renderables + }; } - return null; + } + } + + private static string[] NormalizeToLines(List buffer) { + if (buffer.Count == 0) { + return []; + } + + // Multiple strings in buffer - treat each as a line + if (buffer.Count > 1) { + return [.. buffer]; } - string[] lines = File.ReadAllLines(filePath.FullName); - if (!string.IsNullOrWhiteSpace(Language)) { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language); - WriteVerbose($"Processing file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")})"); - return Converter.ProcessLines(lines, Theme, token, isExtension: asExtension); + // Single string - check if it contains newlines + string single = buffer[0]; + if (string.IsNullOrEmpty(single)) { + return [single]; } - string extension2 = filePath.Extension; - WriteVerbose($"Processing file: {filePath.FullName} using file extension: {extension2}"); - return Converter.ProcessLines(lines, Theme, extension2, isExtension: true); + + // Split on newlines if present + if (single.Contains('\n') || single.Contains('\r')) { + return single.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + } + + // Single string with no newlines + return [single]; + } + + private string GetLanguageDescription() { + return string.IsNullOrWhiteSpace(Language) + ? "(language: default 'powershell')" + : $"(language: '{Language}')"; } } diff --git a/src/Compatibility/Converter.cs b/src/Compatibility/Converter.cs index 4b7d324..29fd568 100644 --- a/src/Compatibility/Converter.cs +++ b/src/Compatibility/Converter.cs @@ -1,5 +1,6 @@ using PwshSpectreConsole.TextMate.Core; using Spectre.Console; +using Spectre.Console.Rendering; using TextMateSharp.Grammars; namespace PwshSpectreConsole.TextMate; @@ -16,8 +17,8 @@ public static class Converter { /// /// /// - public static Spectre.Console.Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { - Core.Rows? rows = TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); - return rows is null ? null : new Spectre.Console.Rows(rows.Renderables); + public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); + return renderables is null ? null : new Rows(renderables); } } diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs new file mode 100644 index 0000000..43c3951 --- /dev/null +++ b/src/Core/HighlightedText.cs @@ -0,0 +1,93 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PwshSpectreConsole.TextMate.Core; + +/// +/// Represents syntax-highlighted text ready for rendering. +/// Provides a clean, consistent output type with optional metadata for streaming scenarios. +/// Implements IRenderable so it can be used directly with Spectre.Console. +/// +public sealed class HighlightedText : Renderable { + /// + /// The highlighted renderables ready for display. + /// + public required IRenderable[] Renderables { get; init; } + + /// + /// Number of lines contained in this highlighted text. + /// + public int LineCount => Renderables.Length; + + /// + /// Optional batch index for streaming scenarios (null for single-batch operations). + /// + public int? BatchIndex { get; init; } + + /// + /// Optional file offset (starting line number) for streaming scenarios (null for single-batch operations). + /// + public long? FileOffset { get; init; } + + /// + /// Indicates whether this is part of a streaming operation. + /// + public bool IsStreaming => BatchIndex.HasValue && FileOffset.HasValue; + + /// + /// Renders the highlighted text by combining all renderables into a single output. + /// + protected override IEnumerable Render(RenderOptions options, int maxWidth) { + // Delegate to Rows which efficiently renders all renderables + var rows = new Rows(Renderables); + return ((IRenderable)rows).Render(options, maxWidth); + } + + /// + /// Measures the dimensions of the highlighted text. + /// + protected override Measurement Measure(RenderOptions options, int maxWidth) { + // Delegate to Rows for measurement + var rows = new Rows(Renderables); + return ((IRenderable)rows).Measure(options, maxWidth); + } + + /// + /// Wraps the highlighted text in a Spectre.Console Panel. + /// + /// Optional panel title + /// Border style to use (default: Rounded) + /// Panel containing the highlighted text + public Panel ToPanel(string? title = null, BoxBorder? border = null) { + Panel panel = new(new Rows([.. Renderables])); + + if (!string.IsNullOrEmpty(title)) { + panel.Header(title); + } + + if (border != null) { + panel.Border(border); + } + else { + panel.Border(BoxBorder.Rounded); + } + + return panel; + } + + // public override string ToString() => ToPanel(); + + /// + /// Wraps the highlighted text with padding. + /// + /// Padding to apply + /// Padder containing the highlighted text + public Padder WithPadding(Padding padding) => new(new Rows([.. Renderables]), padding); + + /// + /// Wraps the highlighted text with uniform padding on all sides. + /// + /// Padding size for all sides + /// Padder containing the highlighted text + public Padder WithPadding(int size) => new(new Rows([.. Renderables]), new Padding(size)); +} diff --git a/src/Core/Markdown/MarkdownRenderer.cs b/src/Core/Markdown/MarkdownRenderer.cs index f94e86c..c42ee7a 100644 --- a/src/Core/Markdown/MarkdownRenderer.cs +++ b/src/Core/Markdown/MarkdownRenderer.cs @@ -19,8 +19,8 @@ internal static class MarkdownRenderer { /// Markdown text (can be multi-line) /// Theme object for styling /// Theme name for TextMateProcessor - /// Rows object for Spectre.Console rendering - public static Rows Render(string markdown, Theme theme, ThemeName themeName) { + /// Array of renderables for Spectre.Console rendering + public static IRenderable[] Render(string markdown, Theme theme, ThemeName themeName) { MarkdownPipeline? pipeline = CreateMarkdownPipeline(); Markdig.Syntax.MarkdownDocument? document = Markdig.Markdown.Parse(markdown, pipeline); @@ -49,7 +49,7 @@ public static Rows Render(string markdown, Theme theme, ThemeName themeName) { } } - return new Rows([.. rows]); + return [.. rows]; } /// diff --git a/src/Core/Markdown/Renderers/BlockRenderer.cs b/src/Core/Markdown/Renderers/BlockRenderer.cs index a44525f..7aa26b5 100644 --- a/src/Core/Markdown/Renderers/BlockRenderer.cs +++ b/src/Core/Markdown/Renderers/BlockRenderer.cs @@ -1,5 +1,6 @@ using Markdig.Extensions.Tables; using Markdig.Syntax; +using Markdig.Syntax.Inlines; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; @@ -21,6 +22,9 @@ internal static class BlockRenderer { /// Rendered block as a Spectre.Console object, or null if unsupported public static IRenderable? RenderBlock(Block block, Theme theme, ThemeName themeName) { return block switch { + // Special handling for paragraphs that contain only an image + ParagraphBlock paragraph when IsStandaloneImage(paragraph) => RenderStandaloneImage(paragraph, theme), + // Use renderers that build Spectre.Console objects directly HeadingBlock heading => HeadingRenderer.Render(heading, theme), ParagraphBlock paragraph => ParagraphRenderer.Render(paragraph, theme), @@ -38,4 +42,88 @@ internal static class BlockRenderer { _ => null }; } + + /// + /// Checks if a paragraph block contains only a single image (no other text). + /// + private static bool IsStandaloneImage(ParagraphBlock paragraph) { + if (paragraph.Inline is null) { + return false; + } + + // Check if the paragraph contains only one LinkInline with IsImage = true + var inlines = paragraph.Inline.ToList(); + + // Single image case + if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { + return true; + } + + // Sometimes there might be whitespace inlines around the image + // Filter out empty/whitespace literals + var nonWhitespace = inlines + .Where(i => i is not LineBreakInline && !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) + .ToList(); + + bool result = nonWhitespace.Count == 1 + && nonWhitespace[0] is LinkInline imageLink + && imageLink.IsImage; + return result; + } + + /// + /// Renders a standalone image (paragraph containing only an image). + /// Demonstrates how SixelImage can be directly rendered or wrapped in containers. + /// + private static IRenderable? RenderStandaloneImage(ParagraphBlock paragraph, Theme theme) { + if (paragraph.Inline is null) { + return null; + } + + // Find the image link + LinkInline? imageLink = paragraph.Inline + .OfType() + .FirstOrDefault(link => link.IsImage); + + if (imageLink is null) { + return null; + } + + // Extract alt text + string altText = ExtractImageAltText(imageLink); + + // Render using ImageBlockRenderer which handles various layouts + // Can render as: Direct (most common), PanelWithCaption, WithPadding, etc. + // This demonstrates how SixelImage (an IRenderable) can be embedded in different containers: + // - Panel: Wrap with border and title + // - Columns: Side-by-side layout + // - Rows: Vertical stacking + // - Grid: Flexible grid layout + // - Table: Inside table cells + // - Or rendered directly without wrapper + + return ImageBlockRenderer.RenderImageBlock( + altText, + imageLink.Url ?? "", + renderMode: ImageRenderMode.Direct); // Direct rendering is most efficient + } + + /// + /// Extracts alt text from an image link inline. + /// + private static string ExtractImageAltText(LinkInline imageLink) { + var textBuilder = new System.Text.StringBuilder(); + + foreach (Inline inline in imageLink) { + if (inline is LiteralInline literal) { + textBuilder.Append(literal.Content.ToString()); + } + else if (inline is CodeInline code) { + textBuilder.Append(code.Content); + } + } + + string result = textBuilder.ToString(); + return string.IsNullOrEmpty(result) ? "Image" : result; + } } diff --git a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs index e9d6d5a..814b53c 100644 --- a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs +++ b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs @@ -31,11 +31,9 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them if (!string.IsNullOrEmpty(language)) { try { - Rows? rows = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); - if (rows is not null) { - // Convert internal Rows into Spectre.Console.Rows for Panel consumption - var spectreRows = new Spectre.Console.Rows(rows.Renderables); - return new Panel(spectreRows) + IRenderable[]? renderables = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); + if (renderables is not null) { + return new Panel(new Rows(renderables)) .Border(BoxBorder.Rounded) .Header(language, Justify.Left); } diff --git a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs index a98d8e2..9d80b03 100644 --- a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs +++ b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs @@ -22,10 +22,9 @@ public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName the // Try to render with HTML syntax highlighting try { - Rows? htmlRows = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); - if (htmlRows is not null) { - var spectreRows = new Spectre.Console.Rows(htmlRows.Renderables); - return new Panel(spectreRows) + IRenderable[]? htmlRenderables = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); + if (htmlRenderables is not null) { + return new Panel(new Rows(htmlRenderables)) .Border(BoxBorder.Rounded) .Header("html", Justify.Left); } diff --git a/src/Core/Markdown/Renderers/ImageBlockRenderer.cs b/src/Core/Markdown/Renderers/ImageBlockRenderer.cs new file mode 100644 index 0000000..576adde --- /dev/null +++ b/src/Core/Markdown/Renderers/ImageBlockRenderer.cs @@ -0,0 +1,190 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Handles rendering of images at the block level with support for captions and layouts. +/// Demonstrates how to embed SixelImage in various Spectre.Console containers. +/// +internal static class ImageBlockRenderer { + /// + /// Renders an image with optional caption using appropriate container. + /// SixelImage can be embedded in Panel, Columns, Rows, Grid, Table cells, or rendered directly. + /// + /// Alternative text / caption for the image + /// URL or path to the image + /// How to render the image (direct, panel, columns, rows) + /// A renderable containing the image + public static IRenderable? RenderImageBlock( + string altText, + string imageUrl, + ImageRenderMode renderMode = ImageRenderMode.Direct) { + + // Get the base image renderable (either SixelImage or fallback) + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + + if (imageRenderable is null) { + return null; + } + + // Apply the rendering mode to wrap/position the image + return renderMode switch { + // Render directly (no wrapper) - good for standalone images + ImageRenderMode.Direct => imageRenderable, + + // Wrap in Panel with title - good for captioned images + ImageRenderMode.PanelWithCaption when !string.IsNullOrEmpty(altText) + => new Panel(imageRenderable) + .Header(altText.EscapeMarkup()) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey), + + // Wrap in Panel without title + ImageRenderMode.PanelWithCaption + => new Panel(imageRenderable) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey), + + // Wrap with padding + ImageRenderMode.WithPadding + => new Padder(imageRenderable, new Padding(1, 0)), + ImageRenderMode.SideCaption => throw new NotImplementedException(), + ImageRenderMode.VerticalCaption => throw new NotImplementedException(), + ImageRenderMode.Grid => throw new NotImplementedException(), + ImageRenderMode.TableCell => throw new NotImplementedException(), + _ => imageRenderable + }; + } + + /// + /// Renders image with text caption in a two-column layout (image | text). + /// Demonstrates using Columns to embed SixelImage with other content. + /// + public static IRenderable? RenderImageWithSideCaption( + string altText, + string imageUrl, + string caption) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + if (imageRenderable is null) { + return null; + } + + // Create a captioned text panel + Panel captionPanel = new Panel(new Markup(caption.EscapeMarkup())) + .Border(BoxBorder.None) + .Padding(0, 1); // Padding on sides + + // Arrange image and caption side-by-side using Columns + // This is how you embed SixelImage (or any IRenderable) horizontally + return new Columns(imageRenderable, captionPanel); + } + + /// + /// Renders image with caption stacked vertically (image on top, caption below). + /// Demonstrates using Rows to embed SixelImage with other content. + /// + public static IRenderable? RenderImageWithVerticalCaption( + string altText, + string imageUrl, + string caption) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + if (imageRenderable is null) { + return null; + } + + // Create caption text + var captionText = new Markup(caption.EscapeMarkup()); + + // Arrange vertically using Rows + // This is how you embed SixelImage (or any IRenderable) vertically + return new Rows( + imageRenderable, + new Padder(captionText, new Padding(0, 1)) // Padding above caption + ); + } + + /// + /// Renders image in a grid layout with optional surrounding content. + /// Demonstrates using Grid to embed SixelImage with flexible positioning. + /// + public static IRenderable? RenderImageInGrid( + string altText, + string imageUrl, + string? topCaption = null, + string? bottomCaption = null) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + if (imageRenderable is null) { + return null; + } + + Grid grid = new Grid() + .AddColumn(new GridColumn { NoWrap = false }) + .AddRow(new Markup(topCaption?.EscapeMarkup() ?? "")); + + grid.AddRow(imageRenderable); + + if (!string.IsNullOrEmpty(bottomCaption)) { + grid.AddRow(new Markup(bottomCaption.EscapeMarkup())); + } + + return grid; + } + + /// + /// Renders image in a table cell with text. + /// Demonstrates embedding SixelImage in Table cells. + /// + public static Table RenderImageInTable( + string altText, + string imageUrl, + string caption) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + + Table table = new Table() + .AddColumn("Image") + .AddColumn("Caption"); + + if (imageRenderable is not null) { + table.AddRow(imageRenderable, new Markup(caption.EscapeMarkup())); + } + else { + table.AddRow( + new Markup($"[grey]Image failed to load: {imageUrl}[/]"), + new Markup(caption.EscapeMarkup()) + ); + } + + return table; + } +} + +/// +/// Specifies how an image should be rendered at the block level. +/// +internal enum ImageRenderMode { + /// Render image directly without any wrapper (most efficient) + Direct, + + /// Wrap image in a Panel with caption as header (good for titled images) + PanelWithCaption, + + /// Wrap image with padding + WithPadding, + + /// Side-by-side layout with caption (requires additional caption text) + SideCaption, + + /// Vertical stack with caption (requires additional caption text) + VerticalCaption, + + /// Grid layout (most flexible, requires additional content) + Grid, + + /// Table cell (requires additional caption) + TableCell +} diff --git a/src/Core/Markdown/Renderers/ImageRenderer.cs b/src/Core/Markdown/Renderers/ImageRenderer.cs index 787f18c..f8a732b 100644 --- a/src/Core/Markdown/Renderers/ImageRenderer.cs +++ b/src/Core/Markdown/Renderers/ImageRenderer.cs @@ -10,11 +10,17 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; /// /// Handles rendering of images in markdown using Sixel format when possible. /// -internal static class ImageRenderer { +public static class ImageRenderer { private static string? _lastSixelError; private static string? _lastImageError; private static readonly TimeSpan ImageTimeout = TimeSpan.FromSeconds(5); // Increased to 5 seconds + /// + /// The base directory for resolving relative image paths in markdown. + /// Set this before rendering markdown content to enable relative path resolution. + /// + public static string? CurrentMarkdownDirectory { get; set; } + /// /// Renders an image using Sixel format if possible, otherwise falls back to a link. /// @@ -37,7 +43,14 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW // Use a timeout for image processing string? localImagePath = null; - Task imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); + Task imageTask = Task.Run(async () => { + string? result = await ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory); + // Track what paths we're trying to resolve for error reporting + if (result is null && CurrentMarkdownDirectory is not null) { + _lastImageError = $"Failed to resolve '{imageUrl}' with base directory '{CurrentMarkdownDirectory}'"; + } + return result; + }); if (imageTask.Wait(ImageTimeout)) { localImagePath = imageTask.Result; @@ -102,7 +115,7 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int // Use a timeout for image processing string? localImagePath = null; - Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); + Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory)); if (imageTask.Wait(ImageTimeout)) { localImagePath = imageTask.Result; @@ -147,12 +160,11 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma try { // Try multiple approaches to find SixelImage - Type? sixelImageType = null; // First, try the direct approach - SixelImage is in Spectre.Console namespace // but might be in different assemblies (Spectre.Console vs Spectre.Console.ImageSharp) - sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); + Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") + ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); // If that fails, search through loaded assemblies if (sixelImageType is null) { @@ -193,18 +205,28 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma // Create SixelImage instance ConstructorInfo? constructor = sixelImageType.GetConstructor([typeof(string), typeof(bool)]); if (constructor is null) { + _lastSixelError = $"Constructor not found for SixelImage with (string, bool) parameters"; + return false; + } + + object? sixelInstance = null; + try { + sixelInstance = constructor.Invoke([imagePath, false]); // false = animation disabled + } + catch (Exception ex) { + _lastSixelError = $"Failed to invoke SixelImage constructor: {ex.InnerException?.Message ?? ex.Message}"; return false; } - object? sixelInstance = constructor.Invoke([imagePath, false]); // false = animation enabled if (sixelInstance is null) { + _lastSixelError = $"SixelImage constructor returned null"; return false; } // Apply size constraints if available if (maxWidth.HasValue) { PropertyInfo? maxWidthProperty = sixelImageType.GetProperty("MaxWidth"); - if (maxWidthProperty is not null && maxWidthProperty.CanWrite) { + if (maxWidthProperty?.CanWrite == true) { maxWidthProperty.SetValue(sixelInstance, maxWidth.Value); } else { @@ -291,28 +313,6 @@ private static Markup CreateImageFallbackInline(string altText, string imageUrl) return new Markup(linkMarkup); } - /// - /// Legacy async method for backward compatibility. Calls the synchronous RenderImage method. - /// - /// Alternative text for the image - /// URL or path to the image - /// Maximum width for the image (optional) - /// Maximum height for the image (optional) - /// A renderable representing the image or fallback - [Obsolete("Use RenderImage instead")] - public static Task RenderImageAsync(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) => Task.FromResult(RenderImage(altText, imageUrl, maxWidth, maxHeight)); - - /// - /// Legacy async method for backward compatibility. Calls the synchronous RenderImageInline method. - /// - /// Alternative text for the image - /// URL or path to the image - /// Maximum width for the image (optional) - /// Maximum height for the image (optional) - /// A renderable representing the image or fallback - [Obsolete("Use RenderImageInline instead")] - public static Task RenderImageInlineAsync(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) => Task.FromResult(RenderImageInline(altText, imageUrl, maxWidth, maxHeight)); - /// /// Gets debug information about the last image processing error. /// @@ -331,11 +331,10 @@ private static Markup CreateImageFallbackInline(string altText, string imageUrl) /// True if SixelImage can be found public static bool IsSixelImageAvailable() { try { - Type? sixelImageType = null; // Try direct approaches first - sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); + Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") + ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); if (sixelImageType is not null) return true; diff --git a/src/Core/Markdown/Renderers/ListRenderer.cs b/src/Core/Markdown/Renderers/ListRenderer.cs index 4e028f1..3b0b1c2 100644 --- a/src/Core/Markdown/Renderers/ListRenderer.cs +++ b/src/Core/Markdown/Renderers/ListRenderer.cs @@ -68,9 +68,7 @@ private static (bool isTaskList, bool isChecked) DetectTaskListItem(ListItemBloc /// /// Creates the appropriate prefix text for list items. /// - private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) { - return isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; - } + private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) => isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; /// /// Creates the appropriate prefix for list items as styled Text objects. diff --git a/src/Core/Markdown/Renderers/ParagraphRenderer.cs b/src/Core/Markdown/Renderers/ParagraphRenderer.cs index f8e6039..6c6c1f8 100644 --- a/src/Core/Markdown/Renderers/ParagraphRenderer.cs +++ b/src/Core/Markdown/Renderers/ParagraphRenderer.cs @@ -230,8 +230,43 @@ private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Them /// /// Processes link inline elements with clickable links using Spectre.Console Style with link parameter. + /// Also handles images (when IsImage is true) by delegating to ImageRenderer. /// private static void ProcessLinkInline(Paragraph paragraph, LinkInline link, Theme theme) { + // Check if this is an image (![alt](url) syntax) + if (link.IsImage) { + // Extract alt text from the link + string altText = ExtractInlineText(link); + if (string.IsNullOrEmpty(altText)) { + altText = "Image"; + } + + // Render the image using ImageRenderer (Sixel support) + IRenderable imageRenderable = ImageRenderer.RenderImageInline(altText, link.Url ?? "", maxWidth: null, maxHeight: null); + + // Note: Can't directly append IRenderable to Paragraph, so we need to handle this differently + // For now, images inside paragraphs will use fallback link representation + // TODO: Consider restructuring to support embedded IRenderable in Paragraph + if (imageRenderable is Markup imageMarkup) { + // If it's a fallback Markup, we can append it + string markupText = imageMarkup.ToString() ?? ""; + paragraph.Append(markupText, Style.Plain); + } + else { + // It's a SixelImage - can't embed in Paragraph inline + // Fall back to link representation + string imageLinkText = $"🖼️ {altText}"; + var imageLinkStyle = new Style( + foreground: Color.Blue, + decoration: Decoration.Underline, + link: link.Url + ); + paragraph.Append(imageLinkText, imageLinkStyle); + } + return; + } + + // Regular link handling // Use link text if available, otherwise use URL string linkText = ExtractInlineText(link); if (string.IsNullOrEmpty(linkText)) { diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index e59358f..d5c644a 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -1,4 +1,5 @@ using PwshSpectreConsole.TextMate.Core.Markdown; +using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; @@ -22,7 +23,7 @@ internal static class MarkdownRenderer { /// Theme name for passing to Markdig renderer /// Optional debug callback (not used by Markdig renderer) /// Rendered rows with markdown syntax highlighting - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) { + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) { string markdown = string.Join("\n", lines); return Markdown.MarkdownRenderer.Render(markdown, theme, themeName); } diff --git a/src/Core/RenderableBatch.cs b/src/Core/RenderableBatch.cs deleted file mode 100644 index 7074bfc..0000000 --- a/src/Core/RenderableBatch.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Spectre.Console; -using Spectre.Console.Rendering; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// A batch container for Spectre.Console renderables used for streaming output in multiple chunks. -/// -public sealed class RenderableBatch(IRenderable[] renderables, int batchIndex = 0, long fileOffset = 0) : IRenderable { - /// - /// Array of renderables that comprise this batch. - /// - public IRenderable[] Renderables { get; } = renderables ?? []; - - /// - /// Zero-based batch index for ordering when streaming. - /// - public int BatchIndex { get; } = batchIndex; - - /// - /// Zero-based file offset (starting line number) for this batch. - /// - public long FileOffset { get; } = fileOffset; - - /// - /// Number of rendered lines (rows) in this batch. - /// - public int LineCount => Renderables?.Length ?? 0; - - /// - /// Renders all contained renderables as segments for Spectre.Console output. - /// - /// Render options specifying terminal constraints - /// Maximum width available for rendering - /// Enumerable of render segments from all renderables in this batch - public IEnumerable Render(RenderOptions options, int maxWidth) { - foreach (IRenderable r in Renderables) { - foreach (Segment s in r.Render(options, maxWidth)) - yield return s; - } - } - - /// - /// Measures the rendering dimensions of all renderables in this batch. - /// - /// Render options specifying terminal constraints - /// Maximum width available for measurement - /// Measurement indicating minimum and maximum width needed - public Measurement Measure(RenderOptions options, int maxWidth) => - // Return a conservative, permissive measurement: min = 0, max = maxWidth. - // This avoids depending on concrete Measurement properties across Spectre.Console versions. - new(0, maxWidth); - - /// - /// Converts this batch to a Spectre.Console Rows object for rendering. - /// - /// Spectre.Console Rows containing all renderables from this batch - public Spectre.Console.Rows ToSpectreRows() => new(Renderables); -} diff --git a/src/Core/Rows.cs b/src/Core/Rows.cs deleted file mode 100644 index 1ce041f..0000000 --- a/src/Core/Rows.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Spectre.Console.Rendering; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// Container for rendered rows returned by renderers. -/// -public sealed record Rows(IRenderable[] Renderables) { - /// - /// Returns an empty Rows container with no renderables. - /// - public static Rows Empty { get; } = new Rows([]); -} diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 385be10..3ececc8 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -20,9 +20,9 @@ internal static class StandardRenderer { /// Theme to apply /// Grammar for tokenization /// Rendered rows with syntax highlighting - public static Rows Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar, null); + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar, null); - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) { + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) { StringBuilder builder = StringBuilderPool.Rent(); List rows = new(lines.Length); @@ -38,7 +38,7 @@ public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action< builder.Clear(); } - return new Rows([.. rows]); + return [.. rows]; } catch (ArgumentException ex) { throw new InvalidOperationException($"Argument error during rendering: {ex.Message}", ex); diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 19cbb04..7ba7454 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -25,7 +25,7 @@ public static class TextMateProcessor { /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { + public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); return lines.Length == 0 || lines.AllIsNullOrEmpty() ? null : ProcessLines(lines, themeName, grammarId, isExtension, null); @@ -43,7 +43,7 @@ public static class TextMateProcessor { /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, Action? debugCallback) { + public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, Action? debugCallback) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); if (lines.Length == 0 || lines.AllIsNullOrEmpty()) { @@ -82,7 +82,7 @@ public static class TextMateProcessor { /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static Rows? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { + public static IRenderable[]? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); try { @@ -113,7 +113,7 @@ public static class TextMateProcessor { /// /// Renders code block lines without escaping markup characters. /// - private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) { + private static IRenderable[] RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) { StringBuilder builder = StringBuilderPool.Rent(); try { List rows = new(lines.Length); @@ -125,13 +125,13 @@ private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar gramma ruleStack = result.RuleStack; TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, debugCallback: null, lineIndex, escapeMarkup: false); string lineMarkup = builder.ToString(); - // Use Text (raw content) for code blocks so markup characters are preserved - // and not interpreted by the Markup parser. - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Text(lineMarkup)); + // Use Markup to parse the color codes generated by TextMateProcessor + // If markup is empty, use an empty Text object instead + rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); builder.Clear(); } - return new Rows([.. rows]); + return [.. rows]; } finally { StringBuilderPool.Return(builder); @@ -140,7 +140,7 @@ private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar gramma /// /// Processes an enumerable of lines in batches to support streaming/low-memory processing. - /// Yields a Rows result for each processed batch. + /// Yields a HighlightedText result for each processed batch with metadata. /// /// Enumerable of text lines to process /// Number of lines to process per batch (default: 1000 lines balances memory usage with throughput) @@ -149,7 +149,7 @@ private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar gramma /// True if grammarId is a file extension, false if it's a language ID /// Token to monitor for cancellation requests /// Optional progress reporter for tracking processing status - /// Enumerable of RenderableBatch objects containing processed lines + /// Enumerable of HighlightedText objects containing processed lines with batch metadata /// Thrown when is null /// Thrown when is less than or equal to zero /// Thrown when grammar cannot be found @@ -160,7 +160,7 @@ private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar gramma /// - Default (1000): Balanced approach for most scenarios /// - Larger batches (2000-5000): Better throughput for large files, higher memory usage /// - public static IEnumerable ProcessLinesInBatches( + public static IEnumerable ProcessLinesInBatches( IEnumerable lines, int batchSize, ThemeName themeName, @@ -190,11 +190,16 @@ public static IEnumerable ProcessLinesInBatches( buffer.Add(line ?? string.Empty); if (buffer.Count >= batchSize) { // Render the batch using the already-loaded grammar and theme - Rows? result = useMarkdownRenderer + IRenderable[]? result = useMarkdownRenderer ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) : StandardRenderer.Render([.. buffer], theme, grammar, null); + if (result is not null) { - yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex, fileOffset: fileOffset); + yield return new HighlightedText { + Renderables = result, + BatchIndex = batchIndex, + FileOffset = fileOffset + }; progress?.Report((batchIndex, fileOffset + batchSize)); batchIndex++; } @@ -207,11 +212,16 @@ public static IEnumerable ProcessLinesInBatches( if (buffer.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); - Rows? result = useMarkdownRenderer + IRenderable[]? result = useMarkdownRenderer ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) : StandardRenderer.Render([.. buffer], theme, grammar, null); + if (result is not null) { - yield return new RenderableBatch(result.Renderables, batchIndex: batchIndex, fileOffset: fileOffset); + yield return new HighlightedText { + Renderables = result, + BatchIndex = batchIndex, + FileOffset = fileOffset + }; progress?.Report((batchIndex, fileOffset + buffer.Count)); } } @@ -220,7 +230,7 @@ public static IEnumerable ProcessLinesInBatches( /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); + public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); /// /// Helper to stream a file by reading lines lazily and processing them in batches. @@ -232,13 +242,13 @@ public static IEnumerable ProcessLinesInBatches( /// True if grammarId is a file extension, false if it's a language ID /// Token to monitor for cancellation requests /// Optional progress reporter for tracking processing status - /// Enumerable of RenderableBatch objects containing processed lines + /// Enumerable of HighlightedText objects containing processed lines with batch metadata /// Thrown when the specified file does not exist /// Thrown when lines enumerable is null /// Thrown when batchSize is less than or equal to zero /// Thrown when grammar cannot be found /// Thrown when cancellation is requested - public static IEnumerable ProcessFileInBatches( + public static IEnumerable ProcessFileInBatches( string filePath, int batchSize, ThemeName themeName, @@ -254,5 +264,5 @@ public static IEnumerable ProcessFileInBatches( /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); + public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); } diff --git a/src/Helpers/ImageFile.cs b/src/Helpers/ImageFile.cs index ca234dc..118ef31 100644 --- a/src/Helpers/ImageFile.cs +++ b/src/Helpers/ImageFile.cs @@ -23,8 +23,9 @@ internal static partial class ImageFile { /// Normalizes an image source to a local file path that can be used by SixelImage. /// /// The image source (file path, URL, or base64 data URI) + /// Optional base directory for resolving relative paths (defaults to current directory) /// A local file path, or null if the image cannot be processed - public static async Task NormalizeImageSourceAsync(string imageSource) { + public static async Task NormalizeImageSourceAsync(string imageSource, string? baseDirectory = null) { if (string.IsNullOrWhiteSpace(imageSource)) { return null; } @@ -47,8 +48,13 @@ internal static partial class ImageFile { } // Try to resolve relative paths - string currentDirectory = Environment.CurrentDirectory; - string fullPath = Path.GetFullPath(Path.Combine(currentDirectory, imageSource)); + // Use provided baseDirectory or fall back to current directory + string resolveBasePath = baseDirectory ?? Environment.CurrentDirectory; + string fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(resolveBasePath, imageSource)); + + // Debug: For troubleshooting, we can add logging here if needed + // System.Diagnostics.Debug.WriteLine($"Resolving '{imageSource}' with base '{resolveBasePath}' -> '{fullPath}' (exists: {File.Exists(fullPath)})"); + return File.Exists(fullPath) ? fullPath : null; } diff --git a/src/Helpers/VTConversion.cs b/src/Helpers/VTConversion.cs index 524b1d4..e202279 100644 --- a/src/Helpers/VTConversion.cs +++ b/src/Helpers/VTConversion.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text; using Spectre.Console; namespace PwshSpectreConsole.TextMate.Core.Helpers; @@ -16,8 +17,9 @@ public static class VTParser { /// /// Parses a string containing VT escape sequences and returns a Paragraph object. + /// Optimized single-pass streaming implementation that avoids intermediate collections. /// This is more efficient than ToMarkup() as it directly constructs the Paragraph - /// without intermediate markup string generation and parsing. + /// without intermediate markup string generation, parsing, or segment collection. /// /// Input string with VT escape sequences /// Paragraph object with parsed styles applied @@ -25,29 +27,7 @@ public static Paragraph ToParagraph(string input) { if (string.IsNullOrEmpty(input)) return new Paragraph(); - List segments = ParseToSegments(input); - if (segments.Count == 0) - return new Paragraph(input, Style.Plain); - var paragraph = new Paragraph(); - foreach (TextSegment segment in segments) { - if (segment.HasStyle) { - // Style class supports links directly via constructor parameter - paragraph.Append(segment.Text, segment.Style.ToSpectreStyle()); - } - else { - paragraph.Append(segment.Text, Style.Plain); - } - } - - return paragraph; - } - - /// - /// Parses input string into styled text segments. - /// - private static List ParseToSegments(string input) { - var segments = new List(); ReadOnlySpan span = input.AsSpan(); var currentStyle = new StyleState(); int textStart = 0; @@ -56,10 +36,15 @@ private static List ParseToSegments(string input) { while (i < span.Length) { if (span[i] == ESC && i + 1 < span.Length) { if (span[i + 1] == CSI_START) { - // Add text segment before escape sequence + // Append text segment before escape sequence if (i > textStart) { string text = input[textStart..i]; - segments.Add(new TextSegment(text, currentStyle.Clone())); + if (currentStyle.HasAnyStyle) { + paragraph.Append(text, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(text, Style.Plain); + } } // Parse CSI escape sequence @@ -73,10 +58,15 @@ private static List ParseToSegments(string input) { } } else if (span[i + 1] == OSC_START) { - // Add text segment before OSC sequence + // Append text segment before OSC sequence if (i > textStart) { string text = input[textStart..i]; - segments.Add(new TextSegment(text, currentStyle.Clone())); + if (currentStyle.HasAnyStyle) { + paragraph.Append(text, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(text, Style.Plain); + } } // Parse OSC sequence @@ -84,7 +74,12 @@ private static List ParseToSegments(string input) { if (oscResult.End > i) { // If we found hyperlink text, add it as a segment if (!string.IsNullOrEmpty(oscResult.LinkText)) { - segments.Add(new TextSegment(oscResult.LinkText, currentStyle.Clone())); + if (currentStyle.HasAnyStyle) { + paragraph.Append(oscResult.LinkText, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(oscResult.LinkText, Style.Plain); + } } i = oscResult.End; textStart = i; @@ -102,33 +97,54 @@ private static List ParseToSegments(string input) { } } - // Add remaining text + // Append remaining text if (textStart < span.Length) { string text = input[textStart..]; - segments.Add(new TextSegment(text, currentStyle.Clone())); + if (currentStyle.HasAnyStyle) { + paragraph.Append(text, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(text, Style.Plain); + } } - return segments; + return paragraph; } /// /// Parses a single VT escape sequence and updates the style state. + /// Uses stack-allocated parameter array for efficient memory usage. /// Returns the index after the escape sequence. /// private static int ParseEscapeSequence(ReadOnlySpan span, int start, ref StyleState style) { int i = start + 2; // Skip ESC[ - var parameters = new List(); + const int MaxEscapeSequenceLength = 1024; + + // Stack-allocate parameter array (SGR sequences typically have < 16 parameters) + Span parameters = stackalloc int[16]; + int paramCount = 0; int currentNumber = 0; bool hasNumber = false; + int escapeLength = 0; - // Parse parameters (numbers separated by semicolons) - while (i < span.Length && span[i] != SGR_END) { + // Parse parameters (numbers separated by semicolons or colons) + while (i < span.Length && span[i] != SGR_END && escapeLength < MaxEscapeSequenceLength) { if (IsDigit(span[i])) { - currentNumber = (currentNumber * 10) + (span[i] - '0'); + // Overflow-safe parsing per XenoAtom pattern + int digit = span[i] - '0'; + if (currentNumber > (int.MaxValue - digit) / 10) { + currentNumber = int.MaxValue; // Clamp instead of overflow + } + else { + currentNumber = (currentNumber * 10) + digit; + } hasNumber = true; } - else if (span[i] == ';') { - parameters.Add(hasNumber ? currentNumber : 0); + // Support both ; and : as separators (SGR uses : for hyperlinks) + else if (span[i] is ';' or ':') { + if (paramCount < parameters.Length) { + parameters[paramCount++] = hasNumber ? currentNumber : 0; + } currentNumber = 0; hasNumber = false; } @@ -137,17 +153,21 @@ private static int ParseEscapeSequence(ReadOnlySpan span, int start, ref S return start + 1; } i++; + escapeLength++; } if (i >= span.Length || span[i] != SGR_END) { - return start + 1; // Invalid sequence + // Invalid sequence + return start + 1; } // Add the last parameter - parameters.Add(hasNumber ? currentNumber : 0); + if (paramCount < parameters.Length) { + parameters[paramCount++] = hasNumber ? currentNumber : 0; + } - // Apply SGR parameters to style - ApplySgrParameters(parameters, ref style); + // Apply SGR parameters to style (using slice of actual parameters) + ApplySgrParameters(parameters[..paramCount], ref style); return i + 1; // Return position after 'm' } @@ -163,9 +183,12 @@ private readonly struct OscResult(int end, string? linkText = null) { /// /// Parses an OSC (Operating System Command) sequence and updates the style state. /// Returns the result containing end position and any link text found. + /// Safety limits prevent memory exhaustion from malformed sequences. /// private static OscResult ParseOscSequence(ReadOnlySpan span, int start, ref StyleState style) { int i = start + 2; // Skip ESC] + const int MaxOscLength = 32768; + int oscLength = 0; // Check if this is OSC 8 (hyperlink) if (i < span.Length && span[i] == '8' && i + 1 < span.Length && span[i + 1] == ';') { @@ -175,24 +198,27 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re int urlEnd = -1; // Find the semicolon that separates params from URL - while (i < span.Length && span[i] != ';') { + while (i < span.Length && span[i] != ';' && oscLength < MaxOscLength) { i++; + oscLength++; } if (i < span.Length && span[i] == ';') { i++; // Skip the semicolon + oscLength++; int urlStart = i; // Find the end of the URL (look for ESC\) - while (i < span.Length - 1) { + while (i < span.Length - 1 && oscLength < MaxOscLength) { if (span[i] == ESC && span[i + 1] == '\\') { urlEnd = i; break; } i++; + oscLength++; } - if (urlEnd > urlStart) { + if (urlEnd > urlStart && urlEnd - urlStart < MaxOscLength) { string url = span[urlStart..urlEnd].ToString(); i = urlEnd + 2; // Skip ESC\ @@ -203,7 +229,7 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re int linkTextEnd = -1; // Look for the closing OSC sequence: ESC]8;;ESC\ - while (i < span.Length - 6) // Need at least 6 chars for ESC]8;;ESC\ + while (i < span.Length - 6 && oscLength < MaxOscLength) // Need at least 6 chars for ESC]8;;ESC\ { if (span[i] == ESC && span[i + 1] == OSC_START && span[i + 2] == '8' && span[i + 3] == ';' && @@ -213,6 +239,7 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re break; } i++; + oscLength++; } if (linkTextEnd > linkTextStart) { @@ -231,11 +258,12 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re } // If we can't parse the OSC sequence, skip to the next ESC\ or end of string - while (i < span.Length - 1) { + while (i < span.Length - 1 && oscLength < MaxOscLength) { if (span[i] == ESC && span[i + 1] == '\\') { return new OscResult(i + 2); } i++; + oscLength++; } return new OscResult(start + 1); // Failed to parse, advance by 1 @@ -243,9 +271,10 @@ private static OscResult ParseOscSequence(ReadOnlySpan span, int start, re /// /// Applies SGR (Select Graphic Rendition) parameters to the style state. + /// Optimized to work with Span instead of List for zero-allocation processing. /// - private static void ApplySgrParameters(List parameters, ref StyleState style) { - for (int i = 0; i < parameters.Count; i++) { + private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleState style) { + for (int i = 0; i < parameters.Length; i++) { int param = parameters[i]; switch (param) { @@ -304,9 +333,9 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl style.Foreground = GetConsoleColor(param); break; case 38: // Extended foreground color - if (i + 1 < parameters.Count) { + if (i + 1 < parameters.Length) { int colorType = parameters[i + 1]; - if (colorType == 2 && i + 4 < parameters.Count) // RGB + if (colorType == 2 && i + 4 < parameters.Length) // RGB { byte r = (byte)Math.Clamp(parameters[i + 2], 0, 255); byte g = (byte)Math.Clamp(parameters[i + 3], 0, 255); @@ -314,7 +343,7 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl style.Foreground = new Color(r, g, b); i += 4; } - else if (colorType == 5 && i + 2 < parameters.Count) // 256-color + else if (colorType == 5 && i + 2 < parameters.Length) // 256-color { int colorIndex = parameters[i + 2]; style.Foreground = Get256Color(colorIndex); @@ -329,9 +358,9 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl style.Background = GetConsoleColor(param); break; case 48: // Extended background color - if (i + 1 < parameters.Count) { + if (i + 1 < parameters.Length) { int colorType = parameters[i + 1]; - if (colorType == 2 && i + 4 < parameters.Count) // RGB + if (colorType == 2 && i + 4 < parameters.Length) // RGB { byte r = (byte)Math.Clamp(parameters[i + 2], 0, 255); byte g = (byte)Math.Clamp(parameters[i + 3], 0, 255); @@ -339,7 +368,7 @@ private static void ApplySgrParameters(List parameters, ref StyleState styl style.Background = new Color(r, g, b); i += 4; } - else if (colorType == 5 && i + 2 < parameters.Count) // 256-color + else if (colorType == 5 && i + 2 < parameters.Length) // 256-color { int colorIndex = parameters[i + 2]; style.Background = Get256Color(colorIndex); @@ -471,17 +500,9 @@ private static Color Get256Color(int index) { [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsDigit(char c) => (uint)(c - '0') <= 9; - /// - /// Represents a text segment with an associated style. - /// - private readonly struct TextSegment(string text, StyleState style) { - public readonly string Text = text; - public readonly StyleState Style = style; - public readonly bool HasStyle = style.HasAnyStyle; - } - /// /// Represents the current style state during parsing. + /// Uses mutable fields with init properties for efficient parsing. /// private struct StyleState { public Color? Foreground; @@ -489,7 +510,9 @@ private struct StyleState { public Decoration Decoration; public string? Link; - public readonly bool HasAnyStyle => Foreground.HasValue || Background.HasValue || Decoration != Decoration.None || !string.IsNullOrEmpty(Link); + public readonly bool HasAnyStyle => + Foreground.HasValue || Background.HasValue || + Decoration != Decoration.None || Link is not null; public void Reset() { Foreground = null; @@ -498,46 +521,71 @@ public void Reset() { Link = null; } - public readonly StyleState Clone() => new() { - Foreground = Foreground, - Background = Background, - Decoration = Decoration, - Link = Link - }; - - public readonly Style ToSpectreStyle() => new(Foreground, Background, Decoration, Link); + public readonly Style ToSpectreStyle() => + new(Foreground, Background, Decoration, Link); public readonly string ToMarkup() { - var parts = new List(); + // Use StringBuilder to avoid List allocation + // Typical markup is <64 chars, so inline capacity avoids resizing + var sb = new StringBuilder(64); if (Foreground.HasValue) { - parts.Add(Foreground.Value.ToMarkup()); + sb.Append(Foreground.Value.ToMarkup()); } else { - parts.Add("Default "); - + sb.Append("Default "); } - if (Background.HasValue) - parts.Add($"on {Background.Value.ToMarkup()}"); + if (Background.HasValue) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("on ").Append(Background.Value.ToMarkup()); + } if (Decoration != Decoration.None) { - if ((Decoration & Decoration.Bold) != 0) parts.Add("bold"); - if ((Decoration & Decoration.Dim) != 0) parts.Add("dim"); - if ((Decoration & Decoration.Italic) != 0) parts.Add("italic"); - if ((Decoration & Decoration.Underline) != 0) parts.Add("underline"); - if ((Decoration & Decoration.Strikethrough) != 0) parts.Add("strikethrough"); - if ((Decoration & Decoration.SlowBlink) != 0) parts.Add("slowblink"); - if ((Decoration & Decoration.RapidBlink) != 0) parts.Add("rapidblink"); - if ((Decoration & Decoration.Invert) != 0) parts.Add("invert"); - if ((Decoration & Decoration.Conceal) != 0) parts.Add("conceal"); + if ((Decoration & Decoration.Bold) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("bold"); + } + if ((Decoration & Decoration.Dim) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("dim"); + } + if ((Decoration & Decoration.Italic) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("italic"); + } + if ((Decoration & Decoration.Underline) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("underline"); + } + if ((Decoration & Decoration.Strikethrough) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("strikethrough"); + } + if ((Decoration & Decoration.SlowBlink) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("slowblink"); + } + if ((Decoration & Decoration.RapidBlink) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("rapidblink"); + } + if ((Decoration & Decoration.Invert) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("invert"); + } + if ((Decoration & Decoration.Conceal) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("conceal"); + } } if (!string.IsNullOrEmpty(Link)) { - parts.Add($"link={Link}"); + if (sb.Length > 0) sb.Append(' '); + sb.Append("link=").Append(Link); } - return string.Join(" ", parts); + return sb.ToString(); } } } diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index 3987e02..6c620ef 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -16,7 +16,7 @@ - + diff --git a/tests/Core/Markdown/MarkdownRendererTests.cs b/tests/Core/Markdown/MarkdownRendererTests.cs index 9810f9f..cf450d5 100644 --- a/tests/Core/Markdown/MarkdownRendererTests.cs +++ b/tests/Core/Markdown/MarkdownRendererTests.cs @@ -18,7 +18,7 @@ public void Render_SimpleMarkdown_ReturnsValidRows() // Assert result.Should().NotBeNull(); - result.Renderables.Should().NotBeEmpty(); + result.Should().NotBeEmpty(); } [Fact] @@ -34,7 +34,7 @@ public void Render_EmptyMarkdown_ReturnsEmptyRows() // Assert result.Should().NotBeNull(); - result.Renderables.Should().BeEmpty(); + result.Should().BeEmpty(); } [Fact] @@ -50,7 +50,7 @@ public void Render_CodeBlock_ProducesCodeBlockRenderer() // Assert result.Should().NotBeNull(); - result.Renderables.Should().NotBeEmpty(); + result.Should().NotBeEmpty(); // Additional assertions for code block rendering can be added } @@ -69,7 +69,7 @@ public void Render_Headings_HandlesAllLevels(string markdownHeading) // Assert result.Should().NotBeNull(); - result.Renderables.Should().HaveCount(1); + result.Should().HaveCount(1); } private static Theme CreateTestTheme() diff --git a/tests/Core/StandardRendererTests.cs b/tests/Core/StandardRendererTests.cs index 7f2f847..e6e2d7b 100644 --- a/tests/Core/StandardRendererTests.cs +++ b/tests/Core/StandardRendererTests.cs @@ -18,7 +18,7 @@ public void Render_WithValidInput_ReturnsRows() // Assert result.Should().NotBeNull(); - result.Renderables.Should().HaveCount(3); + result.Should().HaveCount(3); } [Fact] @@ -33,7 +33,7 @@ public void Render_WithEmptyLines_HandlesGracefully() // Assert result.Should().NotBeNull(); - result.Renderables.Should().HaveCount(3); + result.Should().HaveCount(3); } [Fact] @@ -48,7 +48,7 @@ public void Render_WithSingleLine_ReturnsOneRow() // Assert result.Should().NotBeNull(); - result.Renderables.Should().HaveCount(1); + result.Should().HaveCount(1); } [Fact] @@ -79,8 +79,8 @@ public void Render_PreservesLineOrder() // Assert result.Should().NotBeNull(); - result.Renderables.Should().HaveCount(3); - result.Renderables.Should().ContainInOrder(result.Renderables); + result.Should().HaveCount(3); + result.Should().ContainInOrder(result); } private static (IGrammar grammar, Theme theme) GetTestGrammarAndTheme() diff --git a/tests/Core/TextMateProcessorTests.cs b/tests/Core/TextMateProcessorTests.cs index 2128885..99c1ec7 100644 --- a/tests/Core/TextMateProcessorTests.cs +++ b/tests/Core/TextMateProcessorTests.cs @@ -16,7 +16,7 @@ public void ProcessLines_WithValidInput_ReturnsRows() // Assert result.Should().NotBeNull(); - result!.Renderables.Should().HaveCount(2); + result!.Should().HaveCount(2); } [Fact] @@ -71,7 +71,7 @@ public void ProcessLines_WithExtension_ResolvesGrammar() // Assert result.Should().NotBeNull(); - result!.Renderables.Should().HaveCount(1); + result!.Should().HaveCount(1); } [Fact] @@ -85,7 +85,7 @@ public void ProcessLinesCodeBlock_PreservesRawContent() // Assert result.Should().NotBeNull(); - result!.Renderables.Should().HaveCount(2); + result!.Should().HaveCount(2); } [Fact] @@ -96,14 +96,15 @@ public void ProcessLinesInBatches_WithValidInput_YieldsBatches() int batchSize = 25; // Act - var batches = TextMateProcessor.ProcessLinesInBatches(lines, batchSize, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); + var batches = TextMateProcessor.ProcessLinesInBatches(lines, batchSize, ThemeName.DarkPlus, "powershell", isExtension: false); + var batchList = batches.ToList(); // Assert - batches.Should().HaveCount(4); - batches[0].BatchIndex.Should().Be(0); - batches[0].FileOffset.Should().Be(0); - batches[1].BatchIndex.Should().Be(1); - batches[1].FileOffset.Should().Be(25); + batchList.Should().HaveCount(4); + batchList[0].BatchIndex.Should().Be(0); + batchList[0].FileOffset.Should().Be(0); + batchList[1].BatchIndex.Should().Be(1); + batchList[1].FileOffset.Should().Be(25); } [Fact] @@ -113,7 +114,7 @@ public void ProcessLinesInBatches_WithInvalidBatchSize_ThrowsArgumentOutOfRangeE var lines = new[] { "test" }; // Act - Action act = () => TextMateProcessor.ProcessLinesInBatches(lines, 0, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); + Action act = () => { var _ = TextMateProcessor.ProcessLinesInBatches(lines, 0, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); }; // Assert act.Should().Throw() @@ -127,7 +128,7 @@ public void ProcessFileInBatches_WithNonExistentFile_ThrowsFileNotFoundException string filePath = "non-existent-file.txt"; // Act - Action act = () => TextMateProcessor.ProcessFileInBatches(filePath, 100, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); + Action act = () => { var _ = TextMateProcessor.ProcessFileInBatches(filePath, 100, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); }; // Assert act.Should().Throw(); diff --git a/tests/Integration/TaskListIntegrationTests.cs b/tests/Integration/TaskListIntegrationTests.cs index 52ae4e8..65afe7c 100644 --- a/tests/Integration/TaskListIntegrationTests.cs +++ b/tests/Integration/TaskListIntegrationTests.cs @@ -37,8 +37,8 @@ public void MarkdownRenderer_TaskList_ProducesCorrectCheckboxes() // Since we can't easily inspect the internal structure, we verify that: // 1. No exceptions are thrown (which would happen with reflection issues) // 2. The result is not null - // 3. The Renderables collection is not empty - result.Renderables.Should().NotBeEmpty(); + // 3. The array is not empty + result.Should().NotBeEmpty(); // In a real scenario, the TaskList items would be rendered with proper checkboxes // The fact that this doesn't throw proves the reflection code was successfully removed @@ -59,7 +59,7 @@ public void MarkdownRenderer_VariousTaskListFormats_RendersWithoutErrors(string var result = MarkdownRenderer.Render(markdown, theme, themeName); result.Should().NotBeNull(); - result.Renderables.Should().NotBeEmpty(); + result.Should().NotBeEmpty(); } [Fact] @@ -90,10 +90,10 @@ public void MarkdownRenderer_ComplexTaskList_RendersWithoutReflectionErrors() var result = MarkdownRenderer.Render(markdown, theme, themeName); result.Should().NotBeNull(); - result.Renderables.Should().NotBeEmpty(); + result.Should().NotBeEmpty(); // Verify we have multiple rendered elements (headings, lists, etc.) - result.Renderables.Should().HaveCountGreaterThan(3); + result.Should().HaveCountGreaterThan(3); } [Fact] @@ -107,16 +107,17 @@ public void StreamingProcessFileInBatches_ProducesMultipleBatchesWithOffsets() try { // Act - var batches = TextMate.Core.TextMateProcessor.ProcessFileInBatches(temp, 1000, ThemeName.DarkPlus, ".cs", isExtension: true).ToList(); + var batches = TextMate.Core.TextMateProcessor.ProcessFileInBatches(temp, 1000, ThemeName.DarkPlus, ".cs", isExtension: true); + var batchList = batches.ToList(); // Assert - batches.Should().NotBeEmpty(); - batches.Count.Should().BeGreaterThan(1); + batchList.Should().NotBeEmpty(); + batchList.Count.Should().BeGreaterThan(1); // Offsets should increase and cover the whole file - long covered = batches.Sum(b => b.LineCount); + long covered = batchList.Sum(b => b.LineCount); covered.Should().BeGreaterOrEqualTo(lines.Length); // Batch indexes should be unique and sequential - batches.Select(b => b.BatchIndex).Should().BeInAscendingOrder(); + batchList.Select(b => b.BatchIndex).Should().BeInAscendingOrder(); } finally { diff --git a/tests/PSTextMate.Tests.csproj b/tests/PSTextMate.Tests.csproj index d1e3e10..df53a32 100644 --- a/tests/PSTextMate.Tests.csproj +++ b/tests/PSTextMate.Tests.csproj @@ -19,7 +19,7 @@ - + From bdf1be4c32a91adf19aa08e40bf21ecc8c76af38 Mon Sep 17 00:00:00 2001 From: trackd Date: Thu, 15 Jan 2026 11:05:27 +0100 Subject: [PATCH 08/25] =?UTF-8?q?feat(ShowTextMateCmdlet):=20=E2=9C=A8=20E?= =?UTF-8?q?nhance=20input=20handling=20and=20source=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `InputObject` and `Path` properties to support nullable types. * Added `_sourceExtensionHint` and `_sourceBaseDirectory` for improved source detection. * Updated logic in `ProcessRecord` to handle input more robustly. * Introduced `GetSourceHint` method to extract file extension and base directory from the pipeline. --- src/Cmdlets/ShowTextMateCmdlet.cs | 85 ++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index c5f8c32..f57ffb0 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -1,5 +1,4 @@ using System.Management.Automation; -using PwshSpectreConsole.TextMate; using PwshSpectreConsole.TextMate.Core; using PwshSpectreConsole.TextMate.Extensions; using Spectre.Console.Rendering; @@ -16,6 +15,8 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; [OutputType(typeof(HighlightedText))] public sealed class ShowTextMateCmdlet : PSCmdlet { private readonly List _inputObjectBuffer = []; + private string? _sourceExtensionHint; + private string? _sourceBaseDirectory; /// /// String content to render with syntax highlighting. @@ -26,7 +27,8 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { ParameterSetName = "String" )] [AllowEmptyString] - public string InputObject { get; set; } = string.Empty; + [AllowNull] + public string? InputObject { get; set; } /// /// Path to file to render with syntax highlighting. @@ -39,39 +41,26 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { )] [ValidateNotNullOrEmpty] [Alias("FullName")] - public string Path { get; set; } = string.Empty; + public string? Path { get; set; } /// /// TextMate language ID for syntax highlighting (e.g., 'powershell', 'csharp', 'python'). /// If not specified, detected from file extension (for files) or defaults to 'powershell' (for strings). /// - [Parameter( - ParameterSetName = "String" - )] - [Parameter( - ParameterSetName = "Path" - )] + [Parameter] [ArgumentCompleter(typeof(LanguageCompleter))] public string? Language { get; set; } /// /// Color theme to use for syntax highlighting. /// - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.DarkPlus; - - /// - /// Returns the rendered output object instead of writing directly to host. - /// [Parameter] - public SwitchParameter PassThru { get; set; } + public ThemeName Theme { get; set; } = ThemeName.DarkPlus; /// /// Enables streaming mode for large files, processing in batches. /// - [Parameter( - ParameterSetName = "Path" - )] + [Parameter(ParameterSetName = "Path")] public SwitchParameter Stream { get; set; } /// @@ -84,13 +73,20 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { /// Processes each input record from the pipeline. /// protected override void ProcessRecord() { + WriteVerbose($"ParameterSet: {ParameterSetName}"); + if (ParameterSetName == "String" && InputObject is not null) { - // Simply buffer all strings - no complex detection logic + // Extract extension hint and base directory from PSPath if available + if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { + GetSourceHint(); + } + + // Buffer the input string for later processing _inputObjectBuffer.Add(InputObject); return; } - if (ParameterSetName == "Path" && !string.IsNullOrWhiteSpace(Path)) { + if (ParameterSetName == "Path" && Path is not null) { try { // Process file immediately when in Path parameter set foreach (HighlightedText result in ProcessPathInput()) { @@ -114,14 +110,15 @@ protected override void EndProcessing() { } try { + if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { + GetSourceHint(); + } HighlightedText? result = ProcessStringInput(); if (result is not null) { // Output each renderable directly so pwshspectreconsole can format them WriteObject(result.Renderables, enumerateCollection: true); - if (PassThru) { - WriteVerbose($"Processed {_inputObjectBuffer.Count} line(s) with theme '{Theme}' {GetLanguageDescription()}"); - } } + } catch (Exception ex) { WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); @@ -142,8 +139,13 @@ protected override void EndProcessing() { return null; } - // Resolve language (explicit parameter or default) - string effectiveLanguage = Language ?? "powershell"; + // Resolve language (explicit parameter, pipeline extension hint, or default) + string effectiveLanguage = !string.IsNullOrEmpty(Language) ? Language : + !string.IsNullOrEmpty(_sourceExtensionHint) ? _sourceExtensionHint : + "powershell"; + + WriteVerbose($"effectiveLanguage: {effectiveLanguage}"); + (string? token, bool asExtension) = TextMateResolver.ResolveToken(effectiveLanguage); // Process and wrap in HighlightedText @@ -222,10 +224,33 @@ private static string[] NormalizeToLines(List buffer) { // Single string with no newlines return [single]; } + private void GetSourceHint() { + if (GetVariableValue("_") is not PSObject current) { + WriteVerbose("GetVariableValue failed to cast '_' to psobject"); + return; + } - private string GetLanguageDescription() { - return string.IsNullOrWhiteSpace(Language) - ? "(language: default 'powershell')" - : $"(language: '{Language}')"; + string? hint = current.Properties["PSPath"]?.Value as string + ?? current.Properties["FullName"]?.Value as string; + if (string.IsNullOrEmpty(hint)) { + WriteVerbose($"hint empty?, {current}"); + return; + } + if (_sourceExtensionHint is null) { + string ext = System.IO.Path.GetExtension(hint); + if (!string.IsNullOrWhiteSpace(ext)) { + _sourceExtensionHint = ext; + WriteVerbose($"Detected extension hint from PSPath: {ext}"); + } + } + + if (_sourceBaseDirectory is null) { + string? baseDir = System.IO.Path.GetDirectoryName(hint); + if (!string.IsNullOrWhiteSpace(baseDir)) { + _sourceBaseDirectory = baseDir; + Core.Markdown.Renderers.ImageRenderer.CurrentMarkdownDirectory = baseDir; + WriteVerbose($"Set markdown base directory from PSPath: {baseDir}"); + } + } } } From 34e06714d61d7b961260cae2f881eb8ab5b9a20e Mon Sep 17 00:00:00 2001 From: trackd Date: Thu, 15 Jan 2026 12:00:30 +0100 Subject: [PATCH 09/25] =?UTF-8?q?feat(MarkdownRenderer):=20=E2=9C=A8=20Imp?= =?UTF-8?q?rove=20block=20rendering=20and=20spacing=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhanced the logic for determining spacing between visual and non-visual blocks. * Added methods to check for visual styling and standalone images. * Refactored existing methods for clarity and performance. fix(SpanOptimizedStringExtensions): ✨ Format method for readability * Adjusted method formatting for `ContainsAnyOptimized` for better readability. fix(StringBuilderExtensions): ✨ Format method for readability * Adjusted method formatting for `AppendLink` and `AppendWithStyle` for better readability. fix(StringExtensions): ✨ Format method for readability * Adjusted method formatting for `AllIsNullOrEmpty` for better readability. fix(ImageFile): ✨ Simplify path resolution logic * Removed redundant namespace references for `Path` in `ImageFile` class. --- src/Core/Markdown/MarkdownRenderer.cs | 85 ++++++++++++++----- .../SpanOptimizedStringExtensions.cs | 3 +- src/Extensions/StringBuilderExtensions.cs | 11 +-- src/Extensions/StringExtensions.cs | 3 +- src/Helpers/ImageFile.cs | 6 +- 5 files changed, 79 insertions(+), 29 deletions(-) diff --git a/src/Core/Markdown/MarkdownRenderer.cs b/src/Core/Markdown/MarkdownRenderer.cs index c42ee7a..cec74a3 100644 --- a/src/Core/Markdown/MarkdownRenderer.cs +++ b/src/Core/Markdown/MarkdownRenderer.cs @@ -1,4 +1,6 @@ using Markdig; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; using Spectre.Console; using Spectre.Console.Rendering; @@ -22,30 +24,49 @@ internal static class MarkdownRenderer { /// Array of renderables for Spectre.Console rendering public static IRenderable[] Render(string markdown, Theme theme, ThemeName themeName) { MarkdownPipeline? pipeline = CreateMarkdownPipeline(); - Markdig.Syntax.MarkdownDocument? document = Markdig.Markdown.Parse(markdown, pipeline); + MarkdownDocument? document = Markdig.Markdown.Parse(markdown, pipeline); var rows = new List(); - bool lastWasContent = false; + Block? lastBlock = null; for (int i = 0; i < document.Count; i++) { - Markdig.Syntax.Block? block = document[i]; + Block? block = document[i]; // Use block renderer that builds Spectre.Console objects directly IRenderable? renderable = BlockRenderer.RenderBlock(block, theme, themeName); if (renderable is not null) { - // Add spacing before certain block types or when there was previous content - bool needsSpacing = ShouldAddSpacing(block, lastWasContent); + // Determine if spacing is needed before current block + // Add spacing when transitioning: + // - FROM visual (tables, images, code) TO non-visual (text, headings, lists) + // - FROM non-visual TO visual + // But NOT between two visual blocks (they have their own styling) + bool isCurrentVisual = HasVisualStyling(block); + bool isLastVisual = lastBlock is not null && HasVisualStyling(lastBlock); + + bool needsSpacing = false; + if (lastBlock is not null) { + // Visual to non-visual: add spacing after the visual element + if (isLastVisual && !isCurrentVisual) { + needsSpacing = true; + } + // Non-visual to visual: add spacing before the visual element + else if (!isLastVisual && isCurrentVisual) { + needsSpacing = true; + } + // Non-visual to non-visual: add spacing (paragraph to heading, etc) + else if (!isLastVisual && !isCurrentVisual) { + needsSpacing = true; + } + // Visual to visual: no spacing (they handle their own styling) + } if (needsSpacing && rows.Count > 0) { rows.Add(Text.Empty); } rows.Add(renderable); - lastWasContent = true; - } - else { - lastWasContent = false; + lastBlock = block; } } @@ -68,16 +89,42 @@ private static MarkdownPipeline CreateMarkdownPipeline() { } /// - /// Determines if spacing should be added before a block element. + /// Determines if a block element has visual styling/borders that provide separation. + /// These blocks don't need extra spacing as they're visually distinct. + /// + private static bool HasVisualStyling(Block? block) { + return block is not null && + (block is Markdig.Extensions.Tables.Table || + block is FencedCodeBlock || + block is CodeBlock || + block is QuoteBlock || + block is HtmlBlock || + block is ThematicBreakBlock || + (block is ParagraphBlock para && IsStandaloneImage(para))); + } + + /// + /// Checks if a paragraph block contains only a single image (no other text). /// - /// The current block being rendered - /// Whether the previous element was content - /// True if spacing should be added - private static bool ShouldAddSpacing(Markdig.Syntax.Block block, bool lastWasContent) { - return lastWasContent || - block is Markdig.Syntax.HeadingBlock || - block is Markdig.Syntax.FencedCodeBlock || - block is Markdig.Extensions.Tables.Table || - block is Markdig.Syntax.QuoteBlock; + private static bool IsStandaloneImage(ParagraphBlock paragraph) { + if (paragraph.Inline is null) { + return false; + } + + var inlines = paragraph.Inline.ToList(); + + // Single image case + if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { + return true; + } + + // Filter out empty/whitespace literals + var nonWhitespace = inlines + .Where(i => i is not LineBreakInline && !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) + .ToList(); + + return nonWhitespace.Count == 1 + && nonWhitespace[0] is LinkInline imageLink + && imageLink.IsImage; } } diff --git a/src/Extensions/SpanOptimizedStringExtensions.cs b/src/Extensions/SpanOptimizedStringExtensions.cs index cc9b716..429d403 100644 --- a/src/Extensions/SpanOptimizedStringExtensions.cs +++ b/src/Extensions/SpanOptimizedStringExtensions.cs @@ -123,7 +123,8 @@ public static string TrimOptimized(this string source) { /// Source string to search /// Characters to search for /// True if any character is found - public static bool ContainsAnyOptimized(this string source, ReadOnlySpan chars) => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; + public static bool ContainsAnyOptimized(this string source, ReadOnlySpan chars) + => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; /// /// Replaces characters in a string using span operations for better performance. diff --git a/src/Extensions/StringBuilderExtensions.cs b/src/Extensions/StringBuilderExtensions.cs index b3cc50a..a000733 100644 --- a/src/Extensions/StringBuilderExtensions.cs +++ b/src/Extensions/StringBuilderExtensions.cs @@ -18,10 +18,10 @@ public static class StringBuilderExtensions { /// The same StringBuilder for method chaining public static StringBuilder AppendLink(this StringBuilder builder, string url, string text) { builder.Append("[link=") - .Append(url.EscapeMarkup()) - .Append(']') - .Append(text.EscapeMarkup()) - .Append("[/]"); + .Append(url.EscapeMarkup()) + .Append(']') + .Append(text.EscapeMarkup()) + .Append("[/]"); return builder; } /// @@ -31,7 +31,8 @@ public static StringBuilder AppendLink(this StringBuilder builder, string url, s /// Optional style to apply /// Nullable integer to append /// The same StringBuilder for method chaining - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) => AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) + => AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); /// /// Appends a string value with optional style markup, escaping special characters. diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs index e169cf3..e21cb85 100644 --- a/src/Extensions/StringExtensions.cs +++ b/src/Extensions/StringExtensions.cs @@ -38,5 +38,6 @@ public static string SubstringAtIndexes(this string source, int startIndex, int /// /// Array of strings to check /// True if all strings are null or empty, false otherwise - public static bool AllIsNullOrEmpty(this string[] strings) => strings.All(string.IsNullOrEmpty); + public static bool AllIsNullOrEmpty(this string[] strings) + => strings.All(string.IsNullOrEmpty); } diff --git a/src/Helpers/ImageFile.cs b/src/Helpers/ImageFile.cs index 118ef31..c4bd488 100644 --- a/src/Helpers/ImageFile.cs +++ b/src/Helpers/ImageFile.cs @@ -50,7 +50,7 @@ internal static partial class ImageFile { // Try to resolve relative paths // Use provided baseDirectory or fall back to current directory string resolveBasePath = baseDirectory ?? Environment.CurrentDirectory; - string fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(resolveBasePath, imageSource)); + string fullPath = Path.GetFullPath(Path.Combine(resolveBasePath, imageSource)); // Debug: For troubleshooting, we can add logging here if needed // System.Diagnostics.Debug.WriteLine($"Resolving '{imageSource}' with base '{resolveBasePath}' -> '{fullPath}' (exists: {File.Exists(fullPath)})"); @@ -104,8 +104,8 @@ internal static partial class ImageFile { string? contentType = response.Content.Headers.ContentType?.MediaType; string extension = GetExtensionFromContentType(contentType) ?? - Path.GetExtension(imageUri.LocalPath) ?? - ".img"; + Path.GetExtension(imageUri.LocalPath) ?? + ".img"; string tempFileName = Path.Combine(Path.GetTempPath(), $"pstextmate_img_{Guid.NewGuid():N}{extension}"); From 9a7f2ebde96a5c2ca6c91d8f0268c057642c4e76 Mon Sep 17 00:00:00 2001 From: trackd Date: Fri, 16 Jan 2026 00:16:09 +0100 Subject: [PATCH 10/25] =?UTF-8?q?feat(markdown):=20=E2=9C=A8=20Implement?= =?UTF-8?q?=20visitor=20pattern=20for=20Markdown=20renderers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new renderers for various Markdown elements including `CodeBlock`, `Heading`, `HorizontalRule`, `HtmlBlock`, `List`, `Paragraph`, `Quote`, and `Table`. - Refactored existing renderers to utilize the new visitor pattern for better extensibility and maintainability. - Enhanced `ParagraphRenderer` to skip empty literals and handle line breaks conditionally. - Introduced `SpectreTextMateStyler` for applying TextMate styles to text, with caching for performance. - Added unit tests for Markdown pipelines and styling to ensure functionality and correctness. --- .editorconfig | 2 +- debug.ps1 | 26 --- rep.ps1 | 8 + src/Cmdlets/ShowTextMateCmdlet.cs | 105 +++++------- src/Core/Markdown/InlineProcessor.cs | 46 +++-- src/Core/Markdown/MarkdownPipelines.cs | 26 +++ src/Core/Markdown/MarkdownRenderer.cs | 161 +++++++++++------- .../Markdown/MarkdownRendererCollection.cs | 58 +++++++ src/Core/Markdown/Renderers/BlockRenderer.cs | 7 +- .../Markdown/Renderers/HeadingRenderer.cs | 70 ++++---- .../Renderers/ISpectreMarkdownRenderer.cs | 20 +++ src/Core/Markdown/Renderers/ListRenderer.cs | 141 ++++++++------- .../Markdown/Renderers/ParagraphRenderer.cs | 28 ++- src/Core/Markdown/Renderers/QuoteRenderer.cs | 22 ++- .../Renderers/SpectreCodeBlockRenderer.cs | 26 +++ .../Renderers/SpectreHeadingRenderer.cs | 26 +++ .../SpectreHorizontalRuleRenderer.cs | 17 ++ .../Renderers/SpectreHtmlBlockRenderer.cs | 23 +++ .../Markdown/Renderers/SpectreListRenderer.cs | 21 +++ .../SpectreMarkdownObjectRenderer.cs | 30 ++++ .../Renderers/SpectreParagraphRenderer.cs | 22 +++ .../Renderers/SpectreQuoteRenderer.cs | 21 +++ .../Renderers/SpectreTableRenderer.cs | 21 +++ src/Core/MarkdownRenderer.cs | 5 +- src/Core/StyleHelper.cs | 3 +- src/Core/TextMateStyling/ITextMateStyler.cs | 27 +++ .../TextMateStyling/SpectreTextMateStyler.cs | 68 ++++++++ .../TextMateStyling/TokenStyleProcessor.cs | 72 ++++++++ src/Helpers/VTConversion.cs | 135 ++++++++------- tests/Core/Markdown/MarkdownPipelinesTests.cs | 50 ++++++ .../SpectreTextMateStylerTests.cs | 101 +++++++++++ tests/test-markdown.md | 48 +----- 32 files changed, 1056 insertions(+), 380 deletions(-) delete mode 100644 debug.ps1 create mode 100644 rep.ps1 create mode 100644 src/Core/Markdown/MarkdownPipelines.cs create mode 100644 src/Core/Markdown/MarkdownRendererCollection.cs create mode 100644 src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreListRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs create mode 100644 src/Core/Markdown/Renderers/SpectreTableRenderer.cs create mode 100644 src/Core/TextMateStyling/ITextMateStyler.cs create mode 100644 src/Core/TextMateStyling/SpectreTextMateStyler.cs create mode 100644 src/Core/TextMateStyling/TokenStyleProcessor.cs create mode 100644 tests/Core/Markdown/MarkdownPipelinesTests.cs create mode 100644 tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs diff --git a/.editorconfig b/.editorconfig index df302c1..f0a5377 100644 --- a/.editorconfig +++ b/.editorconfig @@ -109,7 +109,7 @@ csharp_style_implicit_object_creation = true csharp_style_target_typed_new_expression = true csharp_style_pattern_matching_over_is_with_cast_check = true csharp_style_prefer_not_pattern = true - +csharp_style_prefer_primary_constructors = false csharp_style_pattern_matching_over_as_with_null_check = true csharp_style_inlined_variable_declaration = true csharp_style_throw_expression = true diff --git a/debug.ps1 b/debug.ps1 deleted file mode 100644 index ca38645..0000000 --- a/debug.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -<# -$lookup = [TextMateSharp.Grammars.RegistryOptions]::new('red') -$reg = [TextMateSharp.Registry.Registry]::new($lookup) -$theme = $reg.GetTheme() -$theme | Get-Member -MemberType Method -$grammar = $reg.LoadGrammar($lookup.GetScopeByExtension('.md')) -$grammar | Get-Member -MemberType Method -#> - -Push-Location $PSScriptRoot -& ./build.ps1 - -Import-Module ./Module/PSTextMate.psd1 -$md = @' -[fancy title](https://www.google.com) -'@ - -[PwshSpectreConsole.TextMate.Test]::DebugTextMate($md, [TextMateSharp.Grammars.ThemeName]::Dark, 'markdown') -Pop-Location - - -# $x = [Spectre.Console.Style]::new([Spectre.Console.Color]::Aqua, [Spectre.Console.Color]::Default, [Spectre.Console.Decoration]::Underline, 'https://foo.bar') -# [Spectre.Console.Markup]::new('hello', $x) -# [Spectre.Console.Markup]::new("[link=https://foo.com]$([Spectre.Console.Markup]::escape('[foo]'))[/]") -# [Spectre.Console.Markup]::new("[link=https://foo.com]$([Spectre.Console.Markup]::escape('[foo]'))[/]") -# [Spectre.Console.Markup]::new('[link=https://foo.com]foo[/]') diff --git a/rep.ps1 b/rep.ps1 new file mode 100644 index 0000000..d2b6c1d --- /dev/null +++ b/rep.ps1 @@ -0,0 +1,8 @@ +Push-Location $PSScriptRoot +& .\build.ps1 +Import-Module ./output/PSTextMate.psd1 +# $c = Get-Content ./tests/test-markdown.md -Raw +# $c | Show-TextMate -Verbose +# Get-Item ./tests/test-markdown.md | Show-TextMate -Verbose +Show-TextMate -Path ./tests/test-markdown.md -Verbose +Pop-Location diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index f57ffb0..4a77a5d 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -10,7 +10,7 @@ namespace PwshSpectreConsole.TextMate.Cmdlets; /// Cmdlet for displaying syntax-highlighted text using TextMate grammars. /// Supports both string input and file processing with theme customization. /// -[Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "String")] +[Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "Default")] [Alias("st", "Show-Code")] [OutputType(typeof(HighlightedText))] public sealed class ShowTextMateCmdlet : PSCmdlet { @@ -24,24 +24,13 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter( Mandatory = true, ValueFromPipeline = true, - ParameterSetName = "String" + ValueFromPipelineByPropertyName = true )] [AllowEmptyString] [AllowNull] - public string? InputObject { get; set; } + [Alias("FullName", "Path")] - /// - /// Path to file to render with syntax highlighting. - /// - [Parameter( - Mandatory = true, - ValueFromPipelineByPropertyName = true, - ParameterSetName = "Path", - Position = 0 - )] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string? Path { get; set; } + public PSObject? InputObject { get; set; } /// /// TextMate language ID for syntax highlighting (e.g., 'powershell', 'csharp', 'python'). @@ -60,42 +49,50 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { /// /// Enables streaming mode for large files, processing in batches. /// - [Parameter(ParameterSetName = "Path")] + [Parameter] public SwitchParameter Stream { get; set; } /// /// Number of lines to process per batch when streaming (default: 1000). /// - [Parameter(ParameterSetName = "Path")] + [Parameter] public int BatchSize { get; set; } = 1000; /// /// Processes each input record from the pipeline. /// protected override void ProcessRecord() { - WriteVerbose($"ParameterSet: {ParameterSetName}"); - - if (ParameterSetName == "String" && InputObject is not null) { - // Extract extension hint and base directory from PSPath if available - if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { - GetSourceHint(); + if (MyInvocation.ExpectingInput) { + if (InputObject?.BaseObject is FileInfo file) { + try { + foreach (HighlightedText result in ProcessPathInput(file)) { + WriteObject(result.Renderables, enumerateCollection: true); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, file)); + } } + else if (InputObject?.BaseObject is string inputString) { + // Extract extension hint and base directory from PSPath if available + if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { + GetSourceHint(); + } - // Buffer the input string for later processing - _inputObjectBuffer.Add(InputObject); - return; + // Buffer the input string for later processing + _inputObjectBuffer.Add(inputString); + } } - - if (ParameterSetName == "Path" && Path is not null) { + else if (InputObject is not null) { + FileInfo file = new(GetUnresolvedProviderPathFromPSPath(InputObject?.ToString())); + if (!file.Exists) return; try { - // Process file immediately when in Path parameter set - foreach (HighlightedText result in ProcessPathInput()) { - // Output each renderable directly so pwshspectreconsole can format them + foreach (HighlightedText result in ProcessPathInput(file)) { WriteObject(result.Renderables, enumerateCollection: true); } } catch (Exception ex) { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, Path)); + WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, file)); } } } @@ -104,12 +101,12 @@ protected override void ProcessRecord() { /// Finalizes processing after all pipeline records have been processed. /// protected override void EndProcessing() { - // Only process buffered strings in EndProcessing - if (ParameterSetName != "String") { - return; - } try { + if (_inputObjectBuffer.Count == 0) { + // WriteVerbose("No string input provided"); + return; + } if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { GetSourceHint(); } @@ -118,7 +115,6 @@ protected override void EndProcessing() { // Output each renderable directly so pwshspectreconsole can format them WriteObject(result.Renderables, enumerateCollection: true); } - } catch (Exception ex) { WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); @@ -126,11 +122,6 @@ protected override void EndProcessing() { } private HighlightedText? ProcessStringInput() { - if (_inputObjectBuffer.Count == 0) { - WriteVerbose("No input provided"); - return null; - } - // Normalize buffered strings into lines string[] lines = NormalizeToLines(_inputObjectBuffer); @@ -158,8 +149,8 @@ protected override void EndProcessing() { }; } - private IEnumerable ProcessPathInput() { - FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(Path)); + private IEnumerable ProcessPathInput(FileInfo filePath) { + // FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(fileinfo)); if (!filePath.Exists) { throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); @@ -201,6 +192,7 @@ private IEnumerable ProcessPathInput() { } private static string[] NormalizeToLines(List buffer) { + if (buffer.Count == 0) { return []; } @@ -211,9 +203,9 @@ private static string[] NormalizeToLines(List buffer) { } // Single string - check if it contains newlines - string single = buffer[0]; + string? single = buffer[0]; if (string.IsNullOrEmpty(single)) { - return [single]; + return single is not null ? [single] : []; } // Split on newlines if present @@ -225,19 +217,16 @@ private static string[] NormalizeToLines(List buffer) { return [single]; } private void GetSourceHint() { - if (GetVariableValue("_") is not PSObject current) { - WriteVerbose("GetVariableValue failed to cast '_' to psobject"); - return; - } + if (InputObject is null) return; - string? hint = current.Properties["PSPath"]?.Value as string - ?? current.Properties["FullName"]?.Value as string; - if (string.IsNullOrEmpty(hint)) { - WriteVerbose($"hint empty?, {current}"); - return; - } + string? hint = InputObject.Properties["PSPath"]?.Value as string + ?? InputObject.Properties["FullName"]?.Value as string; + if (string.IsNullOrEmpty(hint)) return; + + // remove potential Provider stuff from string. + hint = GetUnresolvedProviderPathFromPSPath(hint); if (_sourceExtensionHint is null) { - string ext = System.IO.Path.GetExtension(hint); + string ext = Path.GetExtension(hint); if (!string.IsNullOrWhiteSpace(ext)) { _sourceExtensionHint = ext; WriteVerbose($"Detected extension hint from PSPath: {ext}"); @@ -245,7 +234,7 @@ private void GetSourceHint() { } if (_sourceBaseDirectory is null) { - string? baseDir = System.IO.Path.GetDirectoryName(hint); + string? baseDir = Path.GetDirectoryName(hint); if (!string.IsNullOrWhiteSpace(baseDir)) { _sourceBaseDirectory = baseDir; Core.Markdown.Renderers.ImageRenderer.CurrentMarkdownDirectory = baseDir; diff --git a/src/Core/Markdown/InlineProcessor.cs b/src/Core/Markdown/InlineProcessor.cs index 0335d08..f200a9e 100644 --- a/src/Core/Markdown/InlineProcessor.cs +++ b/src/Core/Markdown/InlineProcessor.cs @@ -63,14 +63,19 @@ private static void ProcessLiteralInline(LiteralInline literal, StringBuilder bu /// private static void ProcessLinkInline(LinkInline link, Theme theme, StringBuilder builder) { if (!string.IsNullOrEmpty(link.Url)) { - var linkBuilder = new StringBuilder(); - ExtractInlineText(link, theme, linkBuilder); - - if (link.IsImage) { - ProcessImageLink(linkBuilder.ToString(), link.Url, theme, builder); + StringBuilder linkBuilder = StringBuilderPool.Rent(); + try { + ExtractInlineText(link, theme, linkBuilder); + + if (link.IsImage) { + ProcessImageLink(linkBuilder.ToString(), link.Url, theme, builder); + } + else { + builder.AppendLink(link.Url, linkBuilder.ToString()); + } } - else { - builder.AppendLink(link.Url, linkBuilder.ToString()); + finally { + StringBuilderPool.Return(linkBuilder); } } else { @@ -107,20 +112,25 @@ private static void ProcessEmphasisInline(EmphasisInline emph, Theme theme, Stri string[]? emphScopes = MarkdigTextMateScopeMapper.GetInlineScopes("Emphasis", emph.DelimiterCount); (int efg, int ebg, FontStyle efStyle) = TokenProcessor.ExtractThemeProperties(new MarkdownToken(emphScopes), theme); - var emphBuilder = new StringBuilder(); - ExtractInlineText(emph, theme, emphBuilder); + StringBuilder emphBuilder = StringBuilderPool.Rent(); + try { + ExtractInlineText(emph, theme, emphBuilder); - // Apply the theme colors/style to the emphasis text - if (efg != -1 || ebg != -1 || efStyle != FontStyle.NotSet) { - Color emphColor = efg != -1 ? StyleHelper.GetColor(efg, theme) : Color.Default; - Color emphBgColor = ebg != -1 ? StyleHelper.GetColor(ebg, theme) : Color.Default; - Decoration emphDecoration = StyleHelper.GetDecoration(efStyle); + // Apply the theme colors/style to the emphasis text + if (efg != -1 || ebg != -1 || efStyle != FontStyle.NotSet) { + Color emphColor = efg != -1 ? StyleHelper.GetColor(efg, theme) : Color.Default; + Color emphBgColor = ebg != -1 ? StyleHelper.GetColor(ebg, theme) : Color.Default; + Decoration emphDecoration = StyleHelper.GetDecoration(efStyle); - var emphStyle = new Style(emphColor, emphBgColor, emphDecoration); - builder.AppendWithStyle(emphStyle, emphBuilder.ToString()); + var emphStyle = new Style(emphColor, emphBgColor, emphDecoration); + builder.AppendWithStyle(emphStyle, emphBuilder.ToString()); + } + else { + builder.Append(emphBuilder); + } } - else { - builder.Append(emphBuilder); + finally { + StringBuilderPool.Return(emphBuilder); } } diff --git a/src/Core/Markdown/MarkdownPipelines.cs b/src/Core/Markdown/MarkdownPipelines.cs new file mode 100644 index 0000000..82a8835 --- /dev/null +++ b/src/Core/Markdown/MarkdownPipelines.cs @@ -0,0 +1,26 @@ +using Markdig; + +namespace PwshSpectreConsole.TextMate.Core.Markdown; + +/// +/// Provides reusable, pre-configured Markdown pipelines. +/// Pipelines are expensive to create (plugin registration), so they're cached statically. +/// +internal static class MarkdownPipelines { + /// + /// Standard pipeline with all common extensions enabled. + /// Suitable for most Markdown rendering tasks. + /// + public static readonly MarkdownPipeline Standard = BuildStandardPipeline(); + + private static MarkdownPipeline BuildStandardPipeline() { + return new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UsePipeTables() + .UseEmphasisExtras() + .UseAutoLinks() + .UseTaskLists() + .EnableTrackTrivia() + .Build(); + } +} diff --git a/src/Core/Markdown/MarkdownRenderer.cs b/src/Core/Markdown/MarkdownRenderer.cs index cec74a3..7312e20 100644 --- a/src/Core/Markdown/MarkdownRenderer.cs +++ b/src/Core/Markdown/MarkdownRenderer.cs @@ -1,4 +1,7 @@ using Markdig; +using System; +using System.IO; +using Markdig.Helpers; using Markdig.Syntax; using Markdig.Syntax.Inlines; using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -12,95 +15,129 @@ namespace PwshSpectreConsole.TextMate.Core.Markdown; /// /// Markdown renderer that builds Spectre.Console objects directly instead of markup strings. /// This eliminates VT escaping issues and avoids double-parsing overhead for better performance. +/// Supports both traditional switch-based rendering and visitor pattern for extensibility. /// internal static class MarkdownRenderer { + /// + /// Renders markdown content using the visitor pattern with extensible renderer collection. + /// Allows third-party extensions to add custom block renderers. + /// Wraps all blocks in a Rows renderable to ensure proper spacing between them. + /// + /// Markdown text (can be multi-line) + /// Theme object for styling + /// Theme name for TextMateProcessor + /// Optional custom renderer collection (uses default if null) + /// Single Rows renderable containing all markdown blocks + public static IRenderable RenderWithVisitorPattern( + string markdown, + Theme theme, + ThemeName themeName, + MarkdownRendererCollection? rendererCollection = null) { + + // Use cached pipeline for better performance + MarkdownDocument? document = Markdig.Markdown.Parse(markdown, MarkdownPipelines.Standard); + + // Create renderer collection if not provided + rendererCollection ??= new MarkdownRendererCollection(theme, themeName); + + var blocks = new List(); + + for (int i = 0; i < document.Count; i++) { + Block? block = document[i]; + + // Skip redundant paragraph that Markdig sometimes produces on the same line as a table + if (block is ParagraphBlock && i + 1 < document.Count) { + Block nextBlock = document[i + 1]; + if (nextBlock is Markdig.Extensions.Tables.Table table && block.Line == table.Line) { + continue; + } + } + + // Use visitor pattern to dispatch to appropriate renderer + IRenderable? renderable = rendererCollection.Render(block); + + if (renderable is not null) { + blocks.Add(renderable); + } + } + + // Wrap all blocks in Rows to ensure proper line breaks between them + return new Rows([.. blocks]); + } + /// /// Renders markdown content using Spectre.Console object building. /// This approach eliminates VT escaping issues and improves performance. + /// Uses traditional switch-based dispatch for compatibility. + /// Wraps all blocks in a Rows renderable to ensure proper spacing between them. /// /// Markdown text (can be multi-line) /// Theme object for styling /// Theme name for TextMateProcessor - /// Array of renderables for Spectre.Console rendering - public static IRenderable[] Render(string markdown, Theme theme, ThemeName themeName) { - MarkdownPipeline? pipeline = CreateMarkdownPipeline(); - MarkdownDocument? document = Markdig.Markdown.Parse(markdown, pipeline); + /// Single Rows renderable containing all markdown blocks with proper spacing + public static IRenderable Render(string markdown, Theme theme, ThemeName themeName) { + // Use cached pipeline for better performance + MarkdownDocument? document = Markdig.Markdown.Parse(markdown, MarkdownPipelines.Standard); - var rows = new List(); - Block? lastBlock = null; + var blocks = new List(); + Block? previousBlock = null; for (int i = 0; i < document.Count; i++) { Block? block = document[i]; + // Skip redundant paragraph that Markdig sometimes produces on the same line as a table + if (block is ParagraphBlock && i + 1 < document.Count) { + Block nextBlock = document[i + 1]; + if (nextBlock is Markdig.Extensions.Tables.Table table && block.Line == table.Line) { + continue; + } + } + // Use block renderer that builds Spectre.Console objects directly IRenderable? renderable = BlockRenderer.RenderBlock(block, theme, themeName); if (renderable is not null) { - // Determine if spacing is needed before current block - // Add spacing when transitioning: - // - FROM visual (tables, images, code) TO non-visual (text, headings, lists) - // - FROM non-visual TO visual - // But NOT between two visual blocks (they have their own styling) - bool isCurrentVisual = HasVisualStyling(block); - bool isLastVisual = lastBlock is not null && HasVisualStyling(lastBlock); - - bool needsSpacing = false; - if (lastBlock is not null) { - // Visual to non-visual: add spacing after the visual element - if (isLastVisual && !isCurrentVisual) { - needsSpacing = true; + // Preserve source gaps: add a single empty row when there is at least one blank line between blocks + if (previousBlock is not null) { + int gapFromTrivia = block.LinesBefore?.Count ?? 0; + int gapFromLines = block.Line - previousBlock.Line - 1; + int gap = Math.Max(gapFromTrivia, gapFromLines); + + if (gap > 0) { + blocks.Add(Text.Empty); } - // Non-visual to visual: add spacing before the visual element - else if (!isLastVisual && isCurrentVisual) { - needsSpacing = true; - } - // Non-visual to non-visual: add spacing (paragraph to heading, etc) - else if (!isLastVisual && !isCurrentVisual) { - needsSpacing = true; - } - // Visual to visual: no spacing (they handle their own styling) } - if (needsSpacing && rows.Count > 0) { - rows.Add(Text.Empty); - } + blocks.Add(renderable); + previousBlock = block; - rows.Add(renderable); - lastBlock = block; + // Add extra spacing after standalone images (sixel images need breathing room) + if (block is ParagraphBlock para && IsStandaloneImage(para)) { + blocks.Add(Text.Empty); + } } } - return [.. rows]; + // Wrap all blocks in Rows to ensure proper line breaks between them + return new Rows([.. blocks]); } /// - /// Creates the Markdig pipeline with all necessary extensions enabled. + /// Determines how many empty lines preceded this block in the source markdown. + /// Uses Markdig's trivia tracking (LinesBefore) which is enabled in our pipeline. /// - /// Configured MarkdownPipeline - private static MarkdownPipeline CreateMarkdownPipeline() { - return new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .UsePipeTables() - .UseEmphasisExtras() - .UseAutoLinks() - .UseTaskLists() - .EnableTrackTrivia() // Enable HTML support - .Build(); - } + private static int GetEmptyLinesBefore(Block block) { + // LinesBefore contains the empty lines that occurred before this block + // This is only populated when EnableTrackTrivia() is used in the pipeline + List? linesBefore = block.LinesBefore; + + // Don't add spacing before the first block + if (block.Line == 1) { + return 0; + } - /// - /// Determines if a block element has visual styling/borders that provide separation. - /// These blocks don't need extra spacing as they're visually distinct. - /// - private static bool HasVisualStyling(Block? block) { - return block is not null && - (block is Markdig.Extensions.Tables.Table || - block is FencedCodeBlock || - block is CodeBlock || - block is QuoteBlock || - block is HtmlBlock || - block is ThematicBreakBlock || - (block is ParagraphBlock para && IsStandaloneImage(para))); + // If LinesBefore is populated, return the count (we'll add ONE Text.Empty per block) + return linesBefore?.Count ?? 0; } /// @@ -111,6 +148,7 @@ private static bool IsStandaloneImage(ParagraphBlock paragraph) { return false; } + // Check if the paragraph contains only one LinkInline with IsImage = true var inlines = paragraph.Inline.ToList(); // Single image case @@ -118,13 +156,14 @@ private static bool IsStandaloneImage(ParagraphBlock paragraph) { return true; } + // Sometimes there might be whitespace inlines around the image // Filter out empty/whitespace literals var nonWhitespace = inlines .Where(i => i is not LineBreakInline && !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) .ToList(); return nonWhitespace.Count == 1 - && nonWhitespace[0] is LinkInline imageLink - && imageLink.IsImage; + && nonWhitespace[0] is LinkInline imageLink + && imageLink.IsImage; } } diff --git a/src/Core/Markdown/MarkdownRendererCollection.cs b/src/Core/Markdown/MarkdownRendererCollection.cs new file mode 100644 index 0000000..e6814dd --- /dev/null +++ b/src/Core/Markdown/MarkdownRendererCollection.cs @@ -0,0 +1,58 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +namespace PwshSpectreConsole.TextMate.Core.Markdown; + +/// +/// Collection of markdown renderers implementing the visitor pattern. +/// Dispatches markdown objects to appropriate renderers based on type. +/// +internal class MarkdownRendererCollection { + private readonly List _renderers = []; + + /// + /// Initializes the renderer collection with all standard block renderers. + /// + public MarkdownRendererCollection(Theme theme, ThemeName themeName) { + // Register standard block renderers + _renderers.Add(new SpectreHeadingRenderer(theme, themeName)); + _renderers.Add(new SpectreParagraphRenderer(theme, themeName)); + _renderers.Add(new SpectreCodeBlockRenderer(theme, themeName)); + _renderers.Add(new SpectreQuoteRenderer(theme, themeName)); + _renderers.Add(new SpectreListRenderer(theme, themeName)); + _renderers.Add(new SpectreTableRenderer(theme, themeName)); + _renderers.Add(new SpectreHtmlBlockRenderer(theme, themeName)); + _renderers.Add(new SpectreHorizontalRuleRenderer()); + } + + /// + /// Finds and uses the appropriate renderer for a markdown object. + /// + /// Rendered object, or null if no renderer found + public IRenderable? Render(MarkdownObject obj) { + if (obj is null) return null; + + // Find first renderer that accepts this object type + ISpectreMarkdownRenderer? renderer = _renderers.FirstOrDefault(r => r.Accept(obj.GetType())); + + if (renderer is not null) return renderer.Render(obj); + + // Log unmapped type for debugging + // System.Diagnostics.Debug.WriteLine( + // $"No renderer registered for type {obj.GetType().Name}"); + + return null; + } + + /// + /// Adds a custom renderer to the collection. + /// Allows third-party extensions to add support for new block types. + /// + public void Add(ISpectreMarkdownRenderer renderer) { + if (renderer != null) + _renderers.Add(renderer); + } +} diff --git a/src/Core/Markdown/Renderers/BlockRenderer.cs b/src/Core/Markdown/Renderers/BlockRenderer.cs index 7aa26b5..514ff82 100644 --- a/src/Core/Markdown/Renderers/BlockRenderer.cs +++ b/src/Core/Markdown/Renderers/BlockRenderer.cs @@ -65,10 +65,9 @@ private static bool IsStandaloneImage(ParagraphBlock paragraph) { .Where(i => i is not LineBreakInline && !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) .ToList(); - bool result = nonWhitespace.Count == 1 - && nonWhitespace[0] is LinkInline imageLink - && imageLink.IsImage; - return result; + return nonWhitespace.Count == 1 + && nonWhitespace[0] is LinkInline imageLink + && imageLink.IsImage; } /// diff --git a/src/Core/Markdown/Renderers/HeadingRenderer.cs b/src/Core/Markdown/Renderers/HeadingRenderer.cs index ec977fb..f3f8b54 100644 --- a/src/Core/Markdown/Renderers/HeadingRenderer.cs +++ b/src/Core/Markdown/Renderers/HeadingRenderer.cs @@ -2,7 +2,9 @@ using Markdig.Syntax.Inlines; using Spectre.Console; using Spectre.Console.Rendering; +using System.Text; using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -30,7 +32,7 @@ public static IRenderable Render(HeadingBlock heading, Theme theme) { Style headingStyle = CreateHeadingStyle(hfg, hbg, hfs, theme, heading.Level); // Return Text object directly - no markup parsing needed - return new Text(headingText, headingStyle); + return new Rows(new Paragraph(headingText, headingStyle)); } /// @@ -40,42 +42,47 @@ private static string ExtractHeadingText(HeadingBlock heading) { if (heading.Inline is null) return ""; - var textBuilder = new System.Text.StringBuilder(); - - foreach (Inline inline in heading.Inline) { - switch (inline) { - case LiteralInline literal: - textBuilder.Append(literal.Content.ToString()); - break; - - case EmphasisInline emphasis: - // For headings, we'll just extract the text without emphasis styling - // since the heading style takes precedence - ExtractInlineTextRecursive(emphasis, textBuilder); - break; - - case CodeInline code: - textBuilder.Append(code.Content); - break; - - case LinkInline link: - // Extract link text, not the URL - ExtractInlineTextRecursive(link, textBuilder); - break; - - default: - ExtractInlineTextRecursive(inline, textBuilder); - break; + StringBuilder textBuilder = StringBuilderPool.Rent(); + try { + + foreach (Inline inline in heading.Inline) { + switch (inline) { + case LiteralInline literal: + textBuilder.Append(literal.Content.ToString()); + break; + + case EmphasisInline emphasis: + // For headings, we'll just extract the text without emphasis styling + // since the heading style takes precedence + ExtractInlineTextRecursive(emphasis, textBuilder); + break; + + case CodeInline code: + textBuilder.Append(code.Content); + break; + + case LinkInline link: + // Extract link text, not the URL + ExtractInlineTextRecursive(link, textBuilder); + break; + + default: + ExtractInlineTextRecursive(inline, textBuilder); + break; + } } - } - return textBuilder.ToString(); + return textBuilder.ToString(); + } + finally { + StringBuilderPool.Return(textBuilder); + } } /// /// Recursively extracts text from inline elements. /// - private static void ExtractInlineTextRecursive(Inline inline, System.Text.StringBuilder builder) { + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { switch (inline) { case LiteralInline literal: builder.Append(literal.Content.ToString()); @@ -103,7 +110,6 @@ private static void ExtractInlineTextRecursive(Inline inline, System.Text.String private static Style CreateHeadingStyle(int foreground, int background, FontStyle fontStyle, Theme theme, int level) { Color? foregroundColor = null; Color? backgroundColor = null; - Decoration decoration = Decoration.None; // Apply theme colors if available if (foreground != -1) { @@ -115,7 +121,7 @@ private static Style CreateHeadingStyle(int foreground, int background, FontStyl } // Apply font style decorations - decoration = StyleHelper.GetDecoration(fontStyle); + Decoration decoration = StyleHelper.GetDecoration(fontStyle); // Apply level-specific styling as fallbacks foregroundColor ??= GetDefaultHeadingColor(level); diff --git a/src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs b/src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs new file mode 100644 index 0000000..9c4d61e --- /dev/null +++ b/src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs @@ -0,0 +1,20 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Base interface for Spectre.Console markdown object renderers. +/// Follows the visitor pattern for extensible rendering. +/// +public interface ISpectreMarkdownRenderer { + /// + /// Determines if this renderer handles the given object type. + /// + bool Accept(Type objectType); + + /// + /// Renders a markdown object to a Spectre renderable. + /// + IRenderable Render(MarkdownObject obj); +} diff --git a/src/Core/Markdown/Renderers/ListRenderer.cs b/src/Core/Markdown/Renderers/ListRenderer.cs index 3b0b1c2..76288a1 100644 --- a/src/Core/Markdown/Renderers/ListRenderer.cs +++ b/src/Core/Markdown/Renderers/ListRenderer.cs @@ -5,6 +5,7 @@ using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -23,31 +24,29 @@ internal static class ListRenderer { /// /// The list block to render /// Theme for styling - /// Rendered list as a Paragraph with proper styling + /// Rendered list as Rows containing separate Paragraphs for each item public static IRenderable Render(ListBlock list, Theme theme) { - var paragraph = new Paragraph(); + var renderables = new List(); int number = 1; - bool isFirstItem = true; foreach (ListItemBlock item in list.Cast()) { - // Add line break between items (except for the first) - if (!isFirstItem) - paragraph.Append("\n", Style.Plain); + var itemParagraph = new Paragraph(); - // Check if this is a task list item using Markdig's native TaskList support (bool isTaskList, bool isChecked) = DetectTaskListItem(item); - // Build prefix and append it string prefixText = CreateListPrefixText(list.IsOrdered, isTaskList, isChecked, ref number); - paragraph.Append(prefixText, Style.Plain); + itemParagraph.Append(prefixText, Style.Plain); - // Extract and append the item content directly as styled text - AppendListItemContent(paragraph, item, theme); + List nestedRenderables = AppendListItemContent(itemParagraph, item, theme); - isFirstItem = false; + renderables.Add(itemParagraph); + if (nestedRenderables.Count > 0) { + renderables.AddRange(nestedRenderables); + } } - return paragraph; + // Rows will add a single line break after each child + return new Rows([.. renderables]); } /// @@ -68,7 +67,8 @@ private static (bool isTaskList, bool isChecked) DetectTaskListItem(ListItemBloc /// /// Creates the appropriate prefix text for list items. /// - private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) => isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; + private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) + => isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; /// /// Creates the appropriate prefix for list items as styled Text objects. @@ -91,7 +91,9 @@ private static Text CreateListPrefix(bool isOrdered, bool isTaskList, bool isChe /// Appends list item content directly to the paragraph using styled Text objects. /// This eliminates the need for markup parsing and VT escaping. /// - private static void AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme) { + private static List AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme) { + var nestedRenderables = new List(); + foreach (Block subBlock in item) { switch (subBlock) { case ParagraphBlock subPara: @@ -104,19 +106,19 @@ private static void AppendListItemContent(Paragraph paragraph, ListItemBlock ite break; case ListBlock nestedList: - // For nested lists, render as indented text content string nestedContent = RenderNestedListAsText(nestedList, theme, 1); if (!string.IsNullOrEmpty(nestedContent)) { - // Show nested content immediately under the parent without pre-padding - paragraph.Append(nestedContent, Style.Plain); - // Then add a blank line after the nested block to visually separate from following siblings - // paragraph.Append("\n", Style.Plain); + var nestedParagraph = new Paragraph(); + nestedParagraph.Append(nestedContent, Style.Plain); + nestedRenderables.Add(nestedParagraph); } break; default: break; } } + + return nestedRenderables; } /// @@ -126,17 +128,22 @@ private static void AppendListItemContent(Paragraph paragraph, ListItemBlock ite private static void AppendInlineContent(Paragraph paragraph, ContainerInline? inlines, Theme theme) { if (inlines is null) return; - // Use the same advanced processing as ParagraphRenderer - ParagraphRenderer.ProcessInlineElements(paragraph, inlines, theme); + // Skip LineBreakInline for list items; Rows handles separation between list entries + ParagraphRenderer.ProcessInlineElements(paragraph, inlines, theme, skipLineBreaks: true); } /// /// Extracts plain text from inline elements without markup. /// private static string ExtractInlineText(Inline inline) { - var builder = new StringBuilder(); - ExtractInlineTextRecursive(inline, builder); - return builder.ToString(); + StringBuilder builder = StringBuilderPool.Rent(); + try { + ExtractInlineTextRecursive(inline, builder); + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } } /// @@ -169,59 +176,69 @@ private static void ExtractInlineTextRecursive(Inline inline, StringBuilder buil /// Renders nested lists as indented text content. /// private static string RenderNestedListAsText(ListBlock list, Theme theme, int indentLevel) { - var builder = new StringBuilder(); - string indent = new(' ', indentLevel * 2); - int number = 1; - bool isFirstItem = true; + StringBuilder builder = StringBuilderPool.Rent(); + try { + string indent = new(' ', indentLevel * 4); + int number = 1; + bool isFirstItem = true; + + foreach (ListItemBlock item in list.Cast()) { + if (!isFirstItem) { + builder.Append('\n'); + } - foreach (ListItemBlock item in list.Cast()) { - if (!isFirstItem) - builder.Append('\n'); + builder.Append(indent); - builder.Append(indent); + (bool isTaskList, bool isChecked) = DetectTaskListItem(item); - (bool isTaskList, bool isChecked) = DetectTaskListItem(item); + if (isTaskList) { + builder.Append(isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji); + } + else if (list.IsOrdered) { + builder.Append(System.Globalization.CultureInfo.InvariantCulture, $"{number++}. "); + } + else { + builder.Append(UnorderedBullet); + } - if (isTaskList) { - builder.Append(isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji); - } - else if (list.IsOrdered) { - builder.Append(System.Globalization.CultureInfo.InvariantCulture, $"{number++}. "); - } - else { - builder.Append(UnorderedBullet); - } + // Extract item text without complex inline processing for nested items + string itemText = ExtractListItemTextSimple(item); + builder.Append(itemText.Trim()); - // Extract item text without complex inline processing for nested items - string itemText = ExtractListItemTextSimple(item); - builder.Append(itemText.Trim()); + isFirstItem = false; + } - isFirstItem = false; + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); } - - return builder.ToString(); } /// /// Simple text extraction for nested list items. /// private static string ExtractListItemTextSimple(ListItemBlock item) { - var builder = new StringBuilder(); - - foreach (Block subBlock in item) { - if (subBlock is ParagraphBlock subPara && subPara.Inline is not null) { - foreach (Inline inline in subPara.Inline) { - if (inline is not TaskList) // Skip TaskList markers - { - builder.Append(ExtractInlineText(inline)); + StringBuilder builder = StringBuilderPool.Rent(); + try { + foreach (Block subBlock in item) { + if (subBlock is ParagraphBlock subPara && subPara.Inline is not null) { + foreach (Inline inline in subPara.Inline) { + if (inline is not TaskList) { + // Skip TaskList markers + builder.Append(ExtractInlineText(inline)); + } } } + else if (subBlock is CodeBlock subCode) { + builder.Append(subCode.Lines.ToString()); + } } - else if (subBlock is CodeBlock subCode) { - builder.Append(subCode.Lines.ToString()); - } - } - return builder.ToString(); + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } } } diff --git a/src/Core/Markdown/Renderers/ParagraphRenderer.cs b/src/Core/Markdown/Renderers/ParagraphRenderer.cs index 6c6c1f8..559cec8 100644 --- a/src/Core/Markdown/Renderers/ParagraphRenderer.cs +++ b/src/Core/Markdown/Renderers/ParagraphRenderer.cs @@ -8,6 +8,7 @@ using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -39,11 +40,20 @@ public static IRenderable Render(ParagraphBlock paragraph, Theme theme) { /// /// Processes inline elements and adds them directly to the Paragraph with appropriate styling. /// - internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline inlines, Theme theme) { + /// Target Spectre paragraph to append to + /// Markdig inline container + /// Theme for styling + /// If true, skips LineBreakInline (used for list items where Rows handles spacing) + internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline inlines, Theme theme, bool skipLineBreaks = false) { foreach (Inline inline in inlines) { + // Console.WriteLine($"Inline: {inline}"); switch (inline) { case LiteralInline literal: string literalText = literal.Content.ToString(); + // Skip empty literals to avoid extra blank lines + if (string.IsNullOrEmpty(literalText)) { + break; + } // Check for username patterns like @username if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) { @@ -88,7 +98,10 @@ internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline break; case LineBreakInline: - paragraph.Append("\n", Style.Plain); + // Skip line breaks in lists (Rows handles spacing), but keep them in regular paragraphs + if (!skipLineBreaks) { + paragraph.Append("\n", Style.Plain); + } break; case HtmlInline html: @@ -328,9 +341,14 @@ private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline au /// Extracts plain text from inline elements without markup. /// private static string ExtractInlineText(Inline inline) { - var builder = new StringBuilder(); - ExtractInlineTextRecursive(inline, builder); - return builder.ToString(); + StringBuilder builder = StringBuilderPool.Rent(); + try { + ExtractInlineTextRecursive(inline, builder); + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } } /// diff --git a/src/Core/Markdown/Renderers/QuoteRenderer.cs b/src/Core/Markdown/Renderers/QuoteRenderer.cs index c6d04a3..bdb6ad2 100644 --- a/src/Core/Markdown/Renderers/QuoteRenderer.cs +++ b/src/Core/Markdown/Renderers/QuoteRenderer.cs @@ -3,6 +3,7 @@ using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Helpers; namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; @@ -29,18 +30,33 @@ public static IRenderable Render(QuoteBlock quote, Theme theme) { /// private static string ExtractQuoteText(QuoteBlock quote, Theme theme) { string quoteText = string.Empty; + bool isFirstParagraph = true; foreach (Block subBlock in quote) { if (subBlock is ParagraphBlock para) { - var quoteBuilder = new StringBuilder(); - InlineProcessor.ExtractInlineText(para.Inline, theme, quoteBuilder); - quoteText += quoteBuilder.ToString(); + // Add newline between multiple paragraphs + if (!isFirstParagraph) + quoteText += "\n"; + + StringBuilder quoteBuilder = StringBuilderPool.Rent(); + try { + InlineProcessor.ExtractInlineText(para.Inline, theme, quoteBuilder); + quoteText += quoteBuilder.ToString(); + } + finally { + StringBuilderPool.Return(quoteBuilder); + } + + isFirstParagraph = false; } else { quoteText += subBlock.ToString(); } } + // Trim trailing whitespace/newlines + quoteText = quoteText.TrimEnd(); + return quoteText; } } diff --git a/src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs b/src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs new file mode 100644 index 0000000..dfd9091 --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs @@ -0,0 +1,26 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for Markdown code blocks (fenced and indented). +/// +internal class SpectreCodeBlockRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + + public SpectreCodeBlockRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(CodeBlock codeBlock) { + // CodeBlockRenderer has separate methods for FencedCodeBlock and regular CodeBlock + return codeBlock is FencedCodeBlock fenced + ? CodeBlockRenderer.RenderFencedCodeBlock(fenced, _theme, _themeName) + : CodeBlockRenderer.RenderCodeBlock(codeBlock, _theme); + } +} diff --git a/src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs b/src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs new file mode 100644 index 0000000..2729bb6 --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs @@ -0,0 +1,26 @@ +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for Markdown heading blocks. +/// Renders headings with level-based styling using TextMate themes. +/// +internal class SpectreHeadingRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + public SpectreHeadingRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(HeadingBlock heading) => + // Delegate to existing static implementation for now + // This maintains compatibility while adopting visitor pattern + HeadingRenderer.Render(heading, _theme); +} diff --git a/src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs b/src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs new file mode 100644 index 0000000..17cfe20 --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs @@ -0,0 +1,17 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Renders thematic break blocks (horizontal rules) using the visitor pattern. +/// +internal class SpectreHorizontalRuleRenderer : SpectreMarkdownObjectRenderer { + public SpectreHorizontalRuleRenderer() { + } + + protected override IRenderable Render(ThematicBreakBlock block) + => HorizontalRuleRenderer.Render(); +} diff --git a/src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs b/src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs new file mode 100644 index 0000000..d542d1d --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs @@ -0,0 +1,23 @@ +using Markdig.Syntax; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for HTML blocks in Markdown. +/// +internal class SpectreHtmlBlockRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + + public SpectreHtmlBlockRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(HtmlBlock htmlBlock) + => HtmlBlockRenderer.Render(htmlBlock, _theme, _themeName); +} diff --git a/src/Core/Markdown/Renderers/SpectreListRenderer.cs b/src/Core/Markdown/Renderers/SpectreListRenderer.cs new file mode 100644 index 0000000..d6576be --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreListRenderer.cs @@ -0,0 +1,21 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for Markdown list blocks (ordered and unordered). +/// +internal class SpectreListRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + public SpectreListRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(ListBlock list) + => ListRenderer.Render(list, _theme); +} diff --git a/src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs b/src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs new file mode 100644 index 0000000..b39d82d --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs @@ -0,0 +1,30 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Generic base class for markdown object renderers. +/// Implements type checking and dispatch logic. +/// Derived classes need only implement the type-specific Render method. +/// +public abstract class SpectreMarkdownObjectRenderer + : ISpectreMarkdownRenderer + where TObject : MarkdownObject { + /// + /// Checks if this renderer accepts the given object type. + /// + public virtual bool Accept(Type objectType) + => typeof(TObject).IsAssignableFrom(objectType); + + /// + /// Renders a markdown object (dispatches to typed method). + /// + public IRenderable Render(MarkdownObject obj) + => Render((TObject)obj); + + /// + /// Override this method to implement type-specific rendering. + /// + protected abstract IRenderable Render(TObject obj); +} diff --git a/src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs b/src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs new file mode 100644 index 0000000..b86a7e1 --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs @@ -0,0 +1,22 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for Markdown paragraph blocks. +/// +internal class SpectreParagraphRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + + public SpectreParagraphRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(ParagraphBlock paragraph) + => ParagraphRenderer.Render(paragraph, _theme); +} diff --git a/src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs b/src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs new file mode 100644 index 0000000..aa4fac5 --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs @@ -0,0 +1,21 @@ +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for Markdown quote blocks. +/// +internal class SpectreQuoteRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + + public SpectreQuoteRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(QuoteBlock quote) => QuoteRenderer.Render(quote, _theme); +} diff --git a/src/Core/Markdown/Renderers/SpectreTableRenderer.cs b/src/Core/Markdown/Renderers/SpectreTableRenderer.cs new file mode 100644 index 0000000..db40a7e --- /dev/null +++ b/src/Core/Markdown/Renderers/SpectreTableRenderer.cs @@ -0,0 +1,21 @@ +using Markdig.Extensions.Tables; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Visitor-pattern renderer for Markdown table blocks. +/// +internal class SpectreTableRenderer : SpectreMarkdownObjectRenderer { + private readonly Theme _theme; + private readonly ThemeName _themeName; + + public SpectreTableRenderer(Theme theme, ThemeName themeName) { + _theme = theme; + _themeName = themeName; + } + + protected override IRenderable Render(Table table) => TableRenderer.Render(table, _theme)!; +} diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index d5c644a..0c41c3e 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -24,7 +24,8 @@ internal static class MarkdownRenderer { /// Optional debug callback (not used by Markdig renderer) /// Rendered rows with markdown syntax highlighting public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) { - string markdown = string.Join("\n", lines); - return Markdown.MarkdownRenderer.Render(markdown, theme, themeName); + string markdown = string.Join('\n', lines); + IRenderable result = Markdown.MarkdownRenderer.Render(markdown, theme, themeName); + return [result]; } } diff --git a/src/Core/StyleHelper.cs b/src/Core/StyleHelper.cs index fdc8fda..3e6aa76 100644 --- a/src/Core/StyleHelper.cs +++ b/src/Core/StyleHelper.cs @@ -14,7 +14,8 @@ internal static class StyleHelper { /// Color ID from theme /// Theme containing color definitions /// Spectre Console Color instance - public static Color GetColor(int colorId, Theme theme) => colorId == -1 ? Color.Default : HexToColor(theme.GetColor(colorId)); + public static Color GetColor(int colorId, Theme theme) + => colorId == -1 ? Color.Default : HexToColor(theme.GetColor(colorId)); /// /// Converts TextMate font style to Spectre Console decoration. diff --git a/src/Core/TextMateStyling/ITextMateStyler.cs b/src/Core/TextMateStyling/ITextMateStyler.cs new file mode 100644 index 0000000..19b5b29 --- /dev/null +++ b/src/Core/TextMateStyling/ITextMateStyler.cs @@ -0,0 +1,27 @@ +using Spectre.Console; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.TextMateStyling; + +/// +/// Abstraction for applying TextMate token styles to text. +/// Enables reuse of TextMate highlighting in different contexts +/// (code blocks, inline code, etc). +/// +public interface ITextMateStyler { + /// + /// Gets the Spectre Style for a token's scope hierarchy. + /// + /// Token scope hierarchy + /// Theme for color lookup + /// Spectre Style or null if no style found + Style? GetStyleForScopes(IEnumerable scopes, Theme theme); + + /// + /// Applies a style to text. + /// + /// Text to style + /// Style to apply (can be null) + /// Rendered text with style applied + Text ApplyStyle(string text, Style? style); +} diff --git a/src/Core/TextMateStyling/SpectreTextMateStyler.cs b/src/Core/TextMateStyling/SpectreTextMateStyler.cs new file mode 100644 index 0000000..7b9c3e0 --- /dev/null +++ b/src/Core/TextMateStyling/SpectreTextMateStyler.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Spectre.Console; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.TextMateStyling; + +/// +/// Spectre.Console implementation of ITextMateStyler. +/// Caches Style objects to avoid repeated creation. +/// +internal class SpectreTextMateStyler : ITextMateStyler { + /// + /// Cache: (scopesKey, themeHash) → Style + /// + private readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> + _styleCache = new(); + + public Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { + if (scopes == null) + return null; + + // Create cache key from scopes and theme instance + string scopesKey = string.Join(",", scopes); + int themeHash = RuntimeHelpers.GetHashCode(theme); + (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); + + // Return cached style or compute new one + return _styleCache.GetOrAdd(cacheKey, _ => ComputeStyle(scopes, theme)); + } + + public Text ApplyStyle(string text, Style? style) + => string.IsNullOrEmpty(text) ? Text.Empty : new Text(text, style ?? Style.Plain); + + /// + /// Computes the Spectre Style for a scope hierarchy by looking up theme rules. + /// Follows same pattern as TokenProcessor.GetStyleForScopes for consistency. + /// + private static Style? ComputeStyle(IEnumerable scopes, Theme theme) { + // Convert to list if not already (theme.Match expects IList) + IList scopesList = scopes as IList ?? [.. scopes]; + + int foreground = -1; + int background = -1; + FontStyle fontStyle = FontStyle.NotSet; + + // Match all applicable theme rules for this scope hierarchy + foreach (ThemeTrieElementRule? rule in theme.Match(scopesList)) { + if (foreground == -1 && rule.foreground > 0) + foreground = rule.foreground; + if (background == -1 && rule.background > 0) + background = rule.background; + if (fontStyle == FontStyle.NotSet && rule.fontStyle > 0) + fontStyle = rule.fontStyle; + } + + // No matching rules found + if (foreground == -1 && background == -1 && fontStyle == FontStyle.NotSet) + return null; + + // Use StyleHelper for consistent color and decoration conversion + Color? foregroundColor = StyleHelper.GetColor(foreground, theme); + Color? backgroundColor = StyleHelper.GetColor(background, theme); + Decoration decoration = StyleHelper.GetDecoration(fontStyle); + + return new Style(foregroundColor, backgroundColor, decoration); + } +} diff --git a/src/Core/TextMateStyling/TokenStyleProcessor.cs b/src/Core/TextMateStyling/TokenStyleProcessor.cs new file mode 100644 index 0000000..5bf5b85 --- /dev/null +++ b/src/Core/TextMateStyling/TokenStyleProcessor.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.TextMateStyling; + +/// +/// Processes tokens and applies TextMate styling to produce Spectre renderables. +/// Decoupled from specific rendering context (can be used in code blocks, inline code, etc). +/// +internal static class TokenStyleProcessor { + /// + /// Processes tokens from a single line and produces styled Text objects. + /// + /// Tokens from grammar tokenization + /// Source line text + /// Theme for color lookup + /// Styler instance (inject for testability) + /// Array of styled Text renderables + public static IRenderable[] ProcessTokens( + IToken[] tokens, + string line, + Theme theme, + ITextMateStyler styler) { + var result = new List(); + + foreach (IToken token in tokens) { + int startIndex = Math.Min(token.StartIndex, line.Length); + int endIndex = Math.Min(token.EndIndex, line.Length); + + // Skip empty tokens + if (startIndex >= endIndex) + continue; + + // Extract text + string tokenText = line[startIndex..endIndex]; + + // Get style for this token's scopes + Style? style = styler.GetStyleForScopes(token.Scopes, theme); + + // Apply style and add to result + result.Add(styler.ApplyStyle(tokenText, style)); + } + + return [.. result]; + } + + /// + /// Process multiple lines of tokens and return combined renderables. + /// + public static IRenderable[] ProcessLines( + (IToken[] tokens, string line)[] tokenizedLines, + Theme theme, + ITextMateStyler styler) { + var result = new List(); + + foreach ((IToken[]? tokens, string? line) in tokenizedLines) { + // Process each line + IRenderable[] lineRenderables = ProcessTokens(tokens, line, theme, styler); + + // Wrap line's tokens in a Row + if (lineRenderables.Length > 0) + result.Add(new Rows(lineRenderables)); + else + result.Add(Text.Empty); + } + + return [.. result]; + } +} diff --git a/src/Helpers/VTConversion.cs b/src/Helpers/VTConversion.cs index e202279..67afc98 100644 --- a/src/Helpers/VTConversion.cs +++ b/src/Helpers/VTConversion.cs @@ -13,7 +13,6 @@ public static class VTParser { private const char CSI_START = '['; private const char OSC_START = ']'; private const char SGR_END = 'm'; - private const char ST = '\x1B'; // String Terminator (ESC in this context) /// /// Parses a string containing VT escape sequences and returns a Paragraph object. @@ -278,86 +277,108 @@ private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleSt int param = parameters[i]; switch (param) { - case 0: // Reset + case 0: + // Reset style.Reset(); break; - case 1: // Bold + case 1: + // Bold style.Decoration |= Decoration.Bold; break; - case 2: // Dim + case 2: + // Dim style.Decoration |= Decoration.Dim; break; - case 3: // Italic + case 3: + // Italic style.Decoration |= Decoration.Italic; break; - case 4: // Underline + case 4: + // Underline style.Decoration |= Decoration.Underline; break; - case 5: // Slow blink + case 5: + // Slow blink style.Decoration |= Decoration.SlowBlink; break; - case 6: // Rapid blink + case 6: + // Rapid blink style.Decoration |= Decoration.RapidBlink; break; - case 7: // Reverse video + case 7: + // Reverse video style.Decoration |= Decoration.Invert; break; - case 8: // Conceal + case 8: + // Conceal style.Decoration |= Decoration.Conceal; break; - case 9: // Strikethrough + case 9: + // Strikethrough style.Decoration |= Decoration.Strikethrough; break; - case 22: // Normal intensity (not bold or dim) + case 22: + // Normal intensity (not bold or dim) style.Decoration &= ~(Decoration.Bold | Decoration.Dim); break; - case 23: // Not italic + case 23: + // Not italic style.Decoration &= ~Decoration.Italic; break; - case 24: // Not underlined + case 24: + // Not underlined style.Decoration &= ~Decoration.Underline; break; - case 25: // Not blinking + case 25: + // Not blinking style.Decoration &= ~(Decoration.SlowBlink | Decoration.RapidBlink); break; - case 27: // Not reversed + case 27: + // Not reversed style.Decoration &= ~Decoration.Invert; break; - case 28: // Not concealed + case 28: + // Not concealed style.Decoration &= ~Decoration.Conceal; break; - case 29: // Not strikethrough + case 29: + // Not strikethrough style.Decoration &= ~Decoration.Strikethrough; break; - case >= 30 and <= 37: // 3-bit foreground colors + case >= 30 and <= 37: + // 3-bit foreground colors style.Foreground = GetConsoleColor(param); break; - case 38: // Extended foreground color + case 38: + // Extended foreground color if (i + 1 < parameters.Length) { int colorType = parameters[i + 1]; - if (colorType == 2 && i + 4 < parameters.Length) // RGB - { + if (colorType == 2 && i + 4 < parameters.Length) { + // RGB byte r = (byte)Math.Clamp(parameters[i + 2], 0, 255); byte g = (byte)Math.Clamp(parameters[i + 3], 0, 255); byte b = (byte)Math.Clamp(parameters[i + 4], 0, 255); style.Foreground = new Color(r, g, b); i += 4; } - else if (colorType == 5 && i + 2 < parameters.Length) // 256-color - { + else if (colorType == 5 && i + 2 < parameters.Length) { + // 256-color int colorIndex = parameters[i + 2]; style.Foreground = Get256Color(colorIndex); i += 2; } } break; - case 39: // Default foreground color + case 39: + // Default foreground color style.Foreground = null; break; - case >= 40 and <= 47: // 3-bit background colors + case >= 40 and <= 47: + // 3-bit background colors style.Background = GetConsoleColor(param); break; - case 48: // Extended background color + case 48: + // Extended background color if (i + 1 < parameters.Length) { int colorType = parameters[i + 1]; if (colorType == 2 && i + 4 < parameters.Length) // RGB @@ -376,13 +397,16 @@ private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleSt } } break; - case 49: // Default background color + case 49: + // Default background color style.Background = null; break; - case >= 90 and <= 97: // High intensity 3-bit foreground colors + case >= 90 and <= 97: + // High intensity 3-bit foreground colors style.Foreground = GetConsoleColor(param); break; - case >= 100 and <= 107: // High intensity 3-bit background colors + case >= 100 and <= 107: + // High intensity 3-bit background colors style.Background = GetConsoleColor(param); break; default: @@ -394,8 +418,26 @@ private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleSt /// /// Gets a Color object for standard console colors. /// + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Color GetConsoleColor(int code) => code switch { + 30 or 40 => Color.Black, + 31 or 41 => Color.DarkRed, + 32 or 42 => Color.DarkGreen, + 33 or 43 => Color.Olive, + 34 or 44 => Color.DarkBlue, + 35 or 45 => Color.Purple, + 36 or 46 => Color.Teal, + 37 or 47 => Color.Silver, + 90 or 100 => Color.Grey, + 91 or 101 => Color.Red, + 92 or 102 => Color.Green, + 93 or 103 => Color.Yellow, + 94 or 104 => Color.Blue, + 95 or 105 => Color.Fuchsia, + 96 or 106 => Color.Aqua, + 97 or 107 => Color.White, + _ => Color.Default // 30 or 40 => Color.Black, // 31 or 41 => Color.Red, // 32 or 42 => Color.Green, @@ -414,39 +456,6 @@ private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleSt // 97 or 107 => Color.White, // _ => Color.Default // From ConvertFrom-ConsoleColor.ps1 - 30 => Color.Black, - 31 => Color.DarkRed, - 32 => Color.DarkGreen, - 33 => Color.Olive, - 34 => Color.DarkBlue, - 35 => Color.Purple, - 36 => Color.Teal, - 37 => Color.Silver, - 40 => Color.Black, - 41 => Color.DarkRed, - 42 => Color.DarkGreen, - 43 => Color.Olive, - 44 => Color.DarkBlue, - 45 => Color.Purple, - 46 => Color.Teal, - 47 => Color.Silver, - 90 => Color.Grey, - 91 => Color.Red, - 92 => Color.Green, - 93 => Color.Yellow, - 94 => Color.Blue, - 95 => Color.Fuchsia, - 96 => Color.Aqua, - 97 => Color.White, - 100 => Color.Grey, - 101 => Color.Red, - 102 => Color.Green, - 103 => Color.Yellow, - 104 => Color.Blue, - 105 => Color.Fuchsia, - 106 => Color.Aqua, - 107 => Color.White, - _ => Color.Default }; /// diff --git a/tests/Core/Markdown/MarkdownPipelinesTests.cs b/tests/Core/Markdown/MarkdownPipelinesTests.cs new file mode 100644 index 0000000..d3fc91c --- /dev/null +++ b/tests/Core/Markdown/MarkdownPipelinesTests.cs @@ -0,0 +1,50 @@ +using Xunit; +using PwshSpectreConsole.TextMate.Core.Markdown; + +namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; + +public class MarkdownPipelinesTests +{ + [Fact] + public void Standard_ReturnsSamePipelineInstance() + { + var pipeline1 = MarkdownPipelines.Standard; + var pipeline2 = MarkdownPipelines.Standard; + + Assert.Same(pipeline1, pipeline2); + } + + [Fact] + public void Standard_HasRequiredExtensions() + { + var pipeline = MarkdownPipelines.Standard; + + // Pipeline should be configured for tables, emphasis extras, etc. + var markdown = "| Header |\n|--------|\n| Cell |"; + var doc = Markdig.Markdown.Parse(markdown, pipeline); + + Assert.NotNull(doc); + } + + [Fact] + public void Standard_ParsesTaskLists() + { + var pipeline = MarkdownPipelines.Standard; + + var markdown = "- [ ] Task\n- [x] Done"; + var doc = Markdig.Markdown.Parse(markdown, pipeline); + + Assert.NotNull(doc); + } + + [Fact] + public void Standard_ParsesAutoLinks() + { + var pipeline = MarkdownPipelines.Standard; + + var markdown = "https://example.com"; + var doc = Markdig.Markdown.Parse(markdown, pipeline); + + Assert.NotNull(doc); + } +} diff --git a/tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs b/tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs new file mode 100644 index 0000000..c2c8a4a --- /dev/null +++ b/tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs @@ -0,0 +1,101 @@ +using Xunit; +using Spectre.Console; +using TextMateSharp.Themes; +using TextMateSharp.Grammars; +using PwshSpectreConsole.TextMate.Core.TextMateStyling; +using PwshSpectreConsole.TextMate.Infrastructure; + +namespace PwshSpectreConsole.TextMate.Tests.Core.TextMateStyling; + +public class SpectreTextMateStylerTests +{ + private readonly SpectreTextMateStyler _styler; + private readonly Theme _theme; + + public SpectreTextMateStylerTests() + { + _styler = new SpectreTextMateStyler(); + _theme = CreateTestTheme(); + } + + [Fact] + public void GetStyleForScopes_WithValidScopes_ReturnsStyle() + { + var scopes = new[] { "source.cs", "keyword.other.using.cs" }; + + var style = _styler.GetStyleForScopes(scopes, _theme); + + // May return null if theme has no rule for these scopes, which is valid + // Test passes if no exception thrown + Assert.True(true); + } + + [Fact] + public void GetStyleForScopes_CachesResults() + { + var scopes = new[] { "source.cs", "keyword.other.using.cs" }; + + var style1 = _styler.GetStyleForScopes(scopes, _theme); + var style2 = _styler.GetStyleForScopes(scopes, _theme); + + // If styles are returned, should be same instance (cached) + if (style1 != null && style2 != null) + { + Assert.Same(style1, style2); + } + } + + [Fact] + public void GetStyleForScopes_WithEmptyScopes_ReturnsNull() + { + var style = _styler.GetStyleForScopes([], _theme); + Assert.Null(style); + } + + [Fact] + public void GetStyleForScopes_WithNullScopes_ReturnsNull() + { + var style = _styler.GetStyleForScopes(null!, _theme); + Assert.Null(style); + } + + [Fact] + public void ApplyStyle_WithValidText_ReturnsText() + { + var style = new Style(Color.Red); + var text = _styler.ApplyStyle("hello", style); + + Assert.NotNull(text); + } + + [Fact] + public void ApplyStyle_WithNullStyle_ReturnsPlainText() + { + var text = _styler.ApplyStyle("hello", null); + + Assert.NotNull(text); + } + + [Fact] + public void ApplyStyle_WithEmptyString_ReturnsEmptyText() + { + var text = _styler.ApplyStyle("", new Style()); + + Assert.Equal(0, text.Length); + } + + [Fact] + public void ApplyStyle_WithNullString_ReturnsEmptyText() + { + var text = _styler.ApplyStyle(null!, new Style()); + + Assert.Equal(0, text.Length); + } + + private static Theme CreateTestTheme() + { + // Use cached theme for tests + var (_, theme) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); + return theme; + } +} diff --git a/tests/test-markdown.md b/tests/test-markdown.md index 0a87b1a..7c34fc8 100644 --- a/tests/test-markdown.md +++ b/tests/test-markdown.md @@ -12,8 +12,6 @@ This file is for testing all supported markdown features in PSTextMate. ### Heading 3 ---- - ## Paragraphs and Line Breaks This is a paragraph with a line break. @@ -21,27 +19,22 @@ This should be on a new line. This is a new paragraph after a blank line. ---- - ## Emphasis *Italic text* and **bold text** and ***bold italic text***. ---- - ## Links [GitHub](https://github.com) [Blue styled link](https://example.com) ---- - ## Lists - Unordered item 1 - Unordered item 2 - Nested item - Unordered item 3 + - [x] Completed sub-item 3 1. Ordered item 1 2. Ordered item 2 @@ -51,15 +44,11 @@ This is a new paragraph after a blank line. - [x] Completed task - [ ] Incomplete task ---- - ## Blockquote > This is a blockquote. > It can span multiple lines. ---- - ## Code Inline code: `Write-Host "Hello, World!"` @@ -75,23 +64,12 @@ Get-ChildItem $PWD ```csharp // C# code block -public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) -{ - value ??= string.Empty; - if (style is not null) - { - return builder.Append('[') - .Append(style.ToMarkup()) - .Append(']') - .Append(value.EscapeMarkup()) - .Append("[/]"); - } - return builder.Append(value); +public static bool IsSupportedFile(string file) { + string ext = Path.GetExtension(file); + return TextMateHelper.Extensions?.Contains(ext) == true; } ``` ---- - ## Table | Name | Value | @@ -100,14 +78,10 @@ public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? s | Beta | 2 | | Gamma | 3 | ---- - ## Images ![xkcd git](../assets/git_commit.png) ---- - ## Horizontal Rule --- @@ -116,21 +90,7 @@ public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? s
This is raw HTML and may not render in all markdown processors.
---- - ## Escaped Characters \*This is not italic\* \# Not a heading - ---- - -## Second Table - -| Name | Text | -|---------|-------| -| Foo | Bar | -| Hello | World | - ---- -End of test file. From da7fb38ec0ba620418ec81b914ca8898c67c66f2 Mon Sep 17 00:00:00 2001 From: trackd Date: Sat, 17 Jan 2026 00:11:26 +0100 Subject: [PATCH 11/25] =?UTF-8?q?feat(utilities):=20=E2=9C=A8=20Add=20util?= =?UTF-8?q?ity=20classes=20for=20text=20styling=20and=20VT=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced `SpectreTextMateStyler` for caching and applying styles based on TextMate scopes. * Added `StringBuilderExtensions` for optimized string building with Spectre.Console markup. * Implemented `StringBuilderPool` for efficient reuse of `StringBuilder` instances. * Created `StringExtensions` for modern string manipulation using spans. * Developed `TextMateResolver` to resolve language tokens and file extensions. * Added `ThemeExtensions` for converting TextMate themes to Spectre.Console styles. * Implemented `TokenStyleProcessor` for processing tokens and applying styles. * Created `VTParser` for parsing VT escape sequences into Spectre.Console objects. * Added test markdown files to validate spacing and rendering behavior. --- .gitignore | 1 + src/Cmdlets/DebugCmdlets.cs | 275 ------------------ .../ShowTextMateCmdlet.cs | 10 +- src/{Cmdlets => Commands}/SupportCmdlets.cs | 2 +- src/Compatibility/Converter.cs | 24 -- src/{Infrastructure => Core}/CacheManager.cs | 2 +- src/Core/HighlightedText.cs | 2 +- src/Core/MarkdigTextMateScopeMapper.cs | 2 +- src/Core/Markdown/InlineProcessor.cs | 157 ---------- src/Core/Markdown/MarkdownPipelines.cs | 26 -- src/Core/Markdown/MarkdownRenderer.cs | 169 ----------- .../Markdown/MarkdownRendererCollection.cs | 58 ---- .../SpanOptimizedMarkdownProcessor.cs | 201 ------------- src/Core/Markdown/README.md | 136 --------- .../Renderers/ISpectreMarkdownRenderer.cs | 20 -- .../Renderers/SpectreCodeBlockRenderer.cs | 26 -- .../Renderers/SpectreHeadingRenderer.cs | 26 -- .../SpectreHorizontalRuleRenderer.cs | 17 -- .../Renderers/SpectreHtmlBlockRenderer.cs | 23 -- .../Markdown/Renderers/SpectreListRenderer.cs | 21 -- .../SpectreMarkdownObjectRenderer.cs | 30 -- .../Renderers/SpectreParagraphRenderer.cs | 22 -- .../Renderers/SpectreQuoteRenderer.cs | 21 -- .../Renderers/SpectreTableRenderer.cs | 21 -- src/Core/Markdown/Types/MarkdownTypes.cs | 145 --------- src/Core/MarkdownRenderer.cs | 29 +- src/Core/MarkdownToken.cs | 2 +- src/Core/StandardRenderer.cs | 10 +- src/Core/StyleHelper.cs | 2 +- src/Core/TextMateProcessor.cs | 41 +-- src/Core/TokenDebugInfo.cs | 51 ---- src/Core/TokenProcessor.cs | 21 +- src/Core/Validation/MarkdownInputValidator.cs | 100 ------- src/Helpers/Debug.cs | 111 ------- src/PSTextMate.csproj | 2 +- .../Renderers => Rendering}/BlockRenderer.cs | 68 ++--- .../CodeBlockRenderer.cs | 7 +- .../HeadingRenderer.cs | 78 +---- .../HorizontalRuleRenderer.cs | 5 +- .../HtmlBlockRenderer.cs | 3 +- .../ImageBlockRenderer.cs | 23 +- .../Renderers => Rendering}/ImageRenderer.cs | 4 +- .../Renderers => Rendering}/ListRenderer.cs | 57 +--- src/Rendering/MarkdownRenderer.cs | 113 +++++++ .../ParagraphRenderer.cs | 64 ++-- .../Renderers => Rendering}/QuoteRenderer.cs | 8 +- .../Renderers => Rendering}/TableRenderer.cs | 38 +-- src/{Helpers => Utilities}/Completers.cs | 4 +- src/{Helpers => Utilities}/Helpers.cs | 4 +- .../ITextMateStyler.cs | 4 +- src/{Helpers => Utilities}/ImageFile.cs | 4 +- src/Utilities/InlineTextExtractor.cs | 57 ++++ src/Utilities/MarkdownPatterns.cs | 41 +++ .../SpanOptimizedStringExtensions.cs | 2 +- .../SpectreTextMateStyler.cs | 4 +- .../StringBuilderExtensions.cs | 2 +- .../StringBuilderPool.cs | 4 +- .../StringExtensions.cs | 2 +- .../TextMateResolver.cs | 4 +- .../ThemeExtensions.cs | 4 +- .../TokenStyleProcessor.cs | 4 +- src/{Helpers => Utilities}/VTConversion.cs | 4 +- tests/line-test-1.md | 18 ++ tests/line-test-2.md | 18 ++ tests/test-markdown.md | 2 +- 65 files changed, 421 insertions(+), 2035 deletions(-) delete mode 100644 src/Cmdlets/DebugCmdlets.cs rename src/{Cmdlets => Commands}/ShowTextMateCmdlet.cs (94%) rename src/{Cmdlets => Commands}/SupportCmdlets.cs (94%) delete mode 100644 src/Compatibility/Converter.cs rename src/{Infrastructure => Core}/CacheManager.cs (95%) delete mode 100644 src/Core/Markdown/InlineProcessor.cs delete mode 100644 src/Core/Markdown/MarkdownPipelines.cs delete mode 100644 src/Core/Markdown/MarkdownRenderer.cs delete mode 100644 src/Core/Markdown/MarkdownRendererCollection.cs delete mode 100644 src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs delete mode 100644 src/Core/Markdown/README.md delete mode 100644 src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreListRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs delete mode 100644 src/Core/Markdown/Renderers/SpectreTableRenderer.cs delete mode 100644 src/Core/Markdown/Types/MarkdownTypes.cs delete mode 100644 src/Core/TokenDebugInfo.cs delete mode 100644 src/Core/Validation/MarkdownInputValidator.cs delete mode 100644 src/Helpers/Debug.cs rename src/{Core/Markdown/Renderers => Rendering}/BlockRenderer.cs (57%) rename src/{Core/Markdown/Renderers => Rendering}/CodeBlockRenderer.cs (95%) rename src/{Core/Markdown/Renderers => Rendering}/HeadingRenderer.cs (54%) rename src/{Core/Markdown/Renderers => Rendering}/HorizontalRuleRenderer.cs (67%) rename src/{Core/Markdown/Renderers => Rendering}/HtmlBlockRenderer.cs (94%) rename src/{Core/Markdown/Renderers => Rendering}/ImageBlockRenderer.cs (88%) rename src/{Core/Markdown/Renderers => Rendering}/ImageRenderer.cs (97%) rename src/{Core/Markdown/Renderers => Rendering}/ListRenderer.cs (76%) create mode 100644 src/Rendering/MarkdownRenderer.cs rename src/{Core/Markdown/Renderers => Rendering}/ParagraphRenderer.cs (90%) rename src/{Core/Markdown/Renderers => Rendering}/QuoteRenderer.cs (86%) rename src/{Core/Markdown/Renderers => Rendering}/TableRenderer.cs (85%) rename src/{Helpers => Utilities}/Completers.cs (96%) rename src/{Helpers => Utilities}/Helpers.cs (92%) rename src/{Core/TextMateStyling => Utilities}/ITextMateStyler.cs (88%) rename src/{Helpers => Utilities}/ImageFile.cs (95%) create mode 100644 src/Utilities/InlineTextExtractor.cs create mode 100644 src/Utilities/MarkdownPatterns.cs rename src/{Extensions => Utilities}/SpanOptimizedStringExtensions.cs (96%) rename src/{Core/TextMateStyling => Utilities}/SpectreTextMateStyler.cs (94%) rename src/{Extensions => Utilities}/StringBuilderExtensions.cs (96%) rename src/{Helpers => Utilities}/StringBuilderPool.cs (78%) rename src/{Extensions => Utilities}/StringExtensions.cs (95%) rename src/{Helpers => Utilities}/TextMateResolver.cs (91%) rename src/{Extensions => Utilities}/ThemeExtensions.cs (93%) rename src/{Core/TextMateStyling => Utilities}/TokenStyleProcessor.cs (93%) rename src/{Helpers => Utilities}/VTConversion.cs (97%) create mode 100644 tests/line-test-1.md create mode 100644 tests/line-test-2.md diff --git a/.gitignore b/.gitignore index b4a277c..ffaea79 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ debug.md .github/chatmodes/* .github/instructions/* .github/prompts/* +ref/** diff --git a/src/Cmdlets/DebugCmdlets.cs b/src/Cmdlets/DebugCmdlets.cs deleted file mode 100644 index 3144263..0000000 --- a/src/Cmdlets/DebugCmdlets.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System.Management.Automation; -using PwshSpectreConsole.TextMate.Core; -using PwshSpectreConsole.TextMate.Extensions; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Cmdlets; - -/// -/// Cmdlet for debugging TextMate processing and theme application. -/// Provides detailed diagnostic information for troubleshooting rendering issues. -/// -[Cmdlet(VerbsDiagnostic.Debug, "TextMate", DefaultParameterSetName = "String")] -[OutputType(typeof(Test.TextMateDebug))] -public sealed class DebugTextMateCmdlet : PSCmdlet { - private readonly List _inputObjectBuffer = []; - - /// - /// String content to debug. - /// - [Parameter( - Mandatory = true, - ValueFromPipeline = true, - ParameterSetName = "String" - )] - [AllowEmptyString] - public string InputObject { get; set; } = null!; - - /// - /// Path to file to debug. - /// - [Parameter( - Mandatory = true, - ValueFromPipelineByPropertyName = true, - ParameterSetName = "Path", - Position = 0 - )] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string Path { get; set; } = null!; - - /// - /// TextMate language ID (default: 'powershell'). - /// - [Parameter( - ParameterSetName = "String" - )] - [ValidateSet(typeof(TextMateLanguages))] - public string Language { get; set; } = "powershell"; - - /// - /// Color theme for debug output (default: Dark). - /// - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.Dark; - - /// - /// Override file extension for language detection. - /// - [Parameter( - ParameterSetName = "Path" - )] - [TextMateExtensionTransform()] - [ValidateSet(typeof(TextMateExtensions))] - [Alias("As")] - public string ExtensionOverride { get; set; } = null!; - - /// - /// Processes each input record from the pipeline. - /// - protected override void ProcessRecord() { - if (ParameterSetName == "String" && InputObject is not null) { - _inputObjectBuffer.Add(InputObject); - } - } - - /// - /// Finalizes processing and outputs debug information. - /// - protected override void EndProcessing() { - try { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) { - string[] strings = [.. _inputObjectBuffer]; - if (strings.AllIsNullOrEmpty()) { - return; - } - Test.TextMateDebug[]? obj = Test.DebugTextMate(strings, Theme, Language); - WriteObject(obj, true); - } - else if (ParameterSetName == "Path" && Path is not null) { - FileInfo Filepath = new(GetUnresolvedProviderPathFromPSPath(Path)); - if (!Filepath.Exists) { - throw new FileNotFoundException("File not found", Filepath.FullName); - } - string ext = !string.IsNullOrEmpty(ExtensionOverride) - ? ExtensionOverride - : Filepath.Extension; - string[] strings = File.ReadAllLines(Filepath.FullName); - Test.TextMateDebug[]? obj = Test.DebugTextMate(strings, Theme, ext, true); - WriteObject(obj, true); - } - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "DebugTextMateError", ErrorCategory.InvalidOperation, null)); - } - } -} - -/// -/// Cmdlet for debugging individual TextMate tokens and their properties. -/// Provides low-level token analysis for detailed syntax highlighting inspection. -/// -[OutputType(typeof(TokenDebugInfo))] -[Cmdlet(VerbsDiagnostic.Debug, "TextMateTokens", DefaultParameterSetName = "String")] -public sealed class DebugTextMateTokensCmdlet : PSCmdlet { - private readonly List _inputObjectBuffer = []; - - /// - /// String content to analyze tokens from. - /// - [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String")] - [AllowEmptyString] - public string InputObject { get; set; } = null!; - - /// - /// Path to file to analyze tokens from. - /// - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Path", Position = 0)] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string Path { get; set; } = null!; - - /// - /// TextMate language ID (default: 'powershell'). - /// - [Parameter(ParameterSetName = "String")] - [ValidateSet(typeof(TextMateLanguages))] - public string Language { get; set; } = "powershell"; - - /// - /// Color theme for token analysis (default: DarkPlus). - /// - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.DarkPlus; - - /// - /// Override file extension for language detection. - /// - [Parameter(ParameterSetName = "Path")] - [TextMateExtensionTransform()] - [ValidateSet(typeof(TextMateExtensions))] - [Alias("As")] - public string ExtensionOverride { get; set; } = null!; - - /// - /// Processes each input record from the pipeline. - /// - protected override void ProcessRecord() { - if (ParameterSetName == "String" && InputObject is not null) { - _inputObjectBuffer.Add(InputObject); - } - } - - /// - /// Finalizes processing and outputs token debug information. - /// - protected override void EndProcessing() { - try { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) { - string[] strings = [.. _inputObjectBuffer]; - if (strings.AllIsNullOrEmpty()) { - return; - } - TokenDebugInfo[]? obj = Test.DebugTextMateTokens(strings, Theme, Language); - WriteObject(obj, true); - } - else if (ParameterSetName == "Path" && Path is not null) { - FileInfo Filepath = new(GetUnresolvedProviderPathFromPSPath(Path)); - if (!Filepath.Exists) { - throw new FileNotFoundException("File not found", Filepath.FullName); - } - string ext = !string.IsNullOrEmpty(ExtensionOverride) - ? ExtensionOverride - : Filepath.Extension; - string[] strings = File.ReadAllLines(Filepath.FullName); - TokenDebugInfo[]? obj = Test.DebugTextMateTokens(strings, Theme, ext, true); - WriteObject(obj, true); - } - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "DebugTextMateTokensError", ErrorCategory.InvalidOperation, null)); - } - } -} - -/// -/// Cmdlet for debugging Sixel image support and availability. -/// Provides diagnostic information about Sixel capabilities in the current environment. -/// -[Cmdlet(VerbsDiagnostic.Debug, "SixelSupport")] -public sealed class DebugSixelSupportCmdlet : PSCmdlet { - /// - /// Processes the cmdlet and outputs Sixel support diagnostic information. - /// - protected override void ProcessRecord() { - try { - var result = new { - SixelImageAvailable = Core.Markdown.Renderers.ImageRenderer.IsSixelImageAvailable(), - LastSixelError = Core.Markdown.Renderers.ImageRenderer.GetLastSixelError(), - LoadedAssemblies = AppDomain.CurrentDomain.GetAssemblies() - .Where(a => a.GetName().Name?.Contains("Spectre.Console") == true) - .Select(a => new { - a.GetName().Name, - Version = a.GetName().Version?.ToString(), - a.Location, - SixelTypes = a.GetTypes() - .Where(t => t.Name.Contains("Sixel", StringComparison.OrdinalIgnoreCase)) - .Select(t => t.FullName) - .ToArray() - }) - .ToArray() - }; - - WriteObject(result); - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "DebugSixelSupportError", ErrorCategory.InvalidOperation, null)); - } - } -} - -/// -/// Cmdlet for testing image rendering and debugging issues. -/// -[Cmdlet(VerbsDiagnostic.Test, "ImageRendering")] -public sealed class TestImageRenderingCmdlet : PSCmdlet { - /// - /// URL or path to image for rendering test. - /// - [Parameter(Mandatory = true, Position = 0)] - public string ImageUrl { get; set; } = null!; - - /// - /// Alternative text for the image. - /// - [Parameter(Position = 1)] - public string AltText { get; set; } = "Test Image"; - - /// - /// Processes the cmdlet and tests image rendering. - /// - protected override void ProcessRecord() { - try { - WriteVerbose($"Testing image rendering for: {ImageUrl}"); - - IRenderable result = Core.Markdown.Renderers.ImageRenderer.RenderImage(AltText, ImageUrl); - - var debugInfo = new { - ImageUrl, - AltText, - ResultType = result.GetType().FullName, - SixelAvailable = Core.Markdown.Renderers.ImageRenderer.IsSixelImageAvailable(), - LastImageError = Core.Markdown.Renderers.ImageRenderer.GetLastImageError(), - LastSixelError = Core.Markdown.Renderers.ImageRenderer.GetLastSixelError() - }; - - WriteObject(debugInfo); - WriteObject("Rendered result:"); - WriteObject(result); - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "TestImageRenderingError", ErrorCategory.InvalidOperation, ImageUrl)); - } - } -} diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Commands/ShowTextMateCmdlet.cs similarity index 94% rename from src/Cmdlets/ShowTextMateCmdlet.cs rename to src/Commands/ShowTextMateCmdlet.cs index 4a77a5d..0091d19 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Commands/ShowTextMateCmdlet.cs @@ -1,10 +1,10 @@ using System.Management.Automation; -using PwshSpectreConsole.TextMate.Core; -using PwshSpectreConsole.TextMate.Extensions; +using PSTextMate.Core; +using PSTextMate.Utilities; using Spectre.Console.Rendering; using TextMateSharp.Grammars; -namespace PwshSpectreConsole.TextMate.Cmdlets; +namespace PSTextMate.Commands; /// /// Cmdlet for displaying syntax-highlighted text using TextMate grammars. @@ -159,7 +159,7 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { // Set the base directory for relative image path resolution in markdown // Use the full directory path or current directory if not available string markdownBaseDir = filePath.DirectoryName ?? Environment.CurrentDirectory; - Core.Markdown.Renderers.ImageRenderer.CurrentMarkdownDirectory = markdownBaseDir; + Rendering.ImageRenderer.CurrentMarkdownDirectory = markdownBaseDir; WriteVerbose($"Set markdown base directory for image resolution: {markdownBaseDir}"); // Resolve language: explicit parameter > file extension @@ -237,7 +237,7 @@ private void GetSourceHint() { string? baseDir = Path.GetDirectoryName(hint); if (!string.IsNullOrWhiteSpace(baseDir)) { _sourceBaseDirectory = baseDir; - Core.Markdown.Renderers.ImageRenderer.CurrentMarkdownDirectory = baseDir; + Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; WriteVerbose($"Set markdown base directory from PSPath: {baseDir}"); } } diff --git a/src/Cmdlets/SupportCmdlets.cs b/src/Commands/SupportCmdlets.cs similarity index 94% rename from src/Cmdlets/SupportCmdlets.cs rename to src/Commands/SupportCmdlets.cs index 49292b6..0c2ca2d 100644 --- a/src/Cmdlets/SupportCmdlets.cs +++ b/src/Commands/SupportCmdlets.cs @@ -1,7 +1,7 @@ using System.Management.Automation; using TextMateSharp.Grammars; -namespace PwshSpectreConsole.TextMate.Cmdlets; +namespace PSTextMate.Commands; /// /// Cmdlet for testing TextMate support for languages, extensions, and files. diff --git a/src/Compatibility/Converter.cs b/src/Compatibility/Converter.cs deleted file mode 100644 index 29fd568..0000000 --- a/src/Compatibility/Converter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using PwshSpectreConsole.TextMate.Core; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate; - -/// -/// -/// -public static class Converter { - /// - /// - /// - /// - /// - /// - /// - /// - public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { - IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); - return renderables is null ? null : new Rows(renderables); - } -} diff --git a/src/Infrastructure/CacheManager.cs b/src/Core/CacheManager.cs similarity index 95% rename from src/Infrastructure/CacheManager.cs rename to src/Core/CacheManager.cs index 691e59f..e5587af 100644 --- a/src/Infrastructure/CacheManager.cs +++ b/src/Core/CacheManager.cs @@ -3,7 +3,7 @@ using TextMateSharp.Registry; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Infrastructure; +namespace PSTextMate.Core; /// /// Manages caching of expensive TextMate objects for improved performance. diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs index 43c3951..d67b49c 100644 --- a/src/Core/HighlightedText.cs +++ b/src/Core/HighlightedText.cs @@ -1,7 +1,7 @@ using Spectre.Console; using Spectre.Console.Rendering; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Represents syntax-highlighted text ready for rendering. diff --git a/src/Core/MarkdigTextMateScopeMapper.cs b/src/Core/MarkdigTextMateScopeMapper.cs index 7ea517e..7cfe227 100644 --- a/src/Core/MarkdigTextMateScopeMapper.cs +++ b/src/Core/MarkdigTextMateScopeMapper.cs @@ -1,4 +1,4 @@ -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Maps Markdig markdown element types to TextMate scopes for theme lookup. diff --git a/src/Core/Markdown/InlineProcessor.cs b/src/Core/Markdown/InlineProcessor.cs deleted file mode 100644 index f200a9e..0000000 --- a/src/Core/Markdown/InlineProcessor.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Text; -using Markdig.Syntax.Inlines; -using PwshSpectreConsole.TextMate.Extensions; -using PwshSpectreConsole.TextMate.Helpers; -using Spectre.Console; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown; - -/// -/// Handles extraction and styling of inline markdown elements. -/// -internal static class InlineProcessor { - /// - /// Extracts and styles inline text from Markdig inline elements. - /// - /// Container holding inline elements - /// Theme for styling - /// StringBuilder to append results to - public static void ExtractInlineText(ContainerInline? container, Theme theme, StringBuilder builder) { - if (container is null) return; - - foreach (Inline inline in container) { - switch (inline) { - case LiteralInline literal: - ProcessLiteralInline(literal, builder); - break; - - case LinkInline link: - ProcessLinkInline(link, theme, builder); - break; - - case EmphasisInline emph: - ProcessEmphasisInline(emph, theme, builder); - break; - - case CodeInline code: - ProcessCodeInline(code, theme, builder); - break; - - case LineBreakInline: - builder.Append('\n'); - break; - - default: - if (inline is ContainerInline childContainer) - ExtractInlineText(childContainer, theme, builder); - break; - } - } - } - - /// - /// Processes literal text inline elements. - /// - private static void ProcessLiteralInline(LiteralInline literal, StringBuilder builder) { - ReadOnlySpan span = literal.Content.Text.AsSpan(literal.Content.Start, literal.Content.Length); - builder.Append(span); - } - - /// - /// Processes link and image inline elements. - /// - private static void ProcessLinkInline(LinkInline link, Theme theme, StringBuilder builder) { - if (!string.IsNullOrEmpty(link.Url)) { - StringBuilder linkBuilder = StringBuilderPool.Rent(); - try { - ExtractInlineText(link, theme, linkBuilder); - - if (link.IsImage) { - ProcessImageLink(linkBuilder.ToString(), link.Url, theme, builder); - } - else { - builder.AppendLink(link.Url, linkBuilder.ToString()); - } - } - finally { - StringBuilderPool.Return(linkBuilder); - } - } - else { - ExtractInlineText(link, theme, builder); - } - } - - /// - /// Processes image links with special styling. - /// - private static void ProcessImageLink(string altText, string url, Theme theme, StringBuilder builder) { - // For now, render images as enhanced fallback since we can't easily make this async - // In the future, this could be enhanced to support actual Sixel rendering - - // Check if the image format is likely supported - bool isSupported = ImageFile.IsLikelySupportedImageFormat(url); - - if (isSupported) { - // Enhanced image representation for supported formats - builder.Append("🖼️ "); - builder.AppendLink(url, $"Image: {altText} (Sixel-ready)"); - } - else { - // Basic image representation for unsupported formats - builder.Append("🖼️ "); - builder.AppendLink(url, $"Image: {altText}"); - } - } - - /// - /// Processes emphasis inline elements (bold, italic). - /// - private static void ProcessEmphasisInline(EmphasisInline emph, Theme theme, StringBuilder builder) { - string[]? emphScopes = MarkdigTextMateScopeMapper.GetInlineScopes("Emphasis", emph.DelimiterCount); - (int efg, int ebg, FontStyle efStyle) = TokenProcessor.ExtractThemeProperties(new MarkdownToken(emphScopes), theme); - - StringBuilder emphBuilder = StringBuilderPool.Rent(); - try { - ExtractInlineText(emph, theme, emphBuilder); - - // Apply the theme colors/style to the emphasis text - if (efg != -1 || ebg != -1 || efStyle != FontStyle.NotSet) { - Color emphColor = efg != -1 ? StyleHelper.GetColor(efg, theme) : Color.Default; - Color emphBgColor = ebg != -1 ? StyleHelper.GetColor(ebg, theme) : Color.Default; - Decoration emphDecoration = StyleHelper.GetDecoration(efStyle); - - var emphStyle = new Style(emphColor, emphBgColor, emphDecoration); - builder.AppendWithStyle(emphStyle, emphBuilder.ToString()); - } - else { - builder.Append(emphBuilder); - } - } - finally { - StringBuilderPool.Return(emphBuilder); - } - } - - /// - /// Processes inline code elements. - /// - private static void ProcessCodeInline(CodeInline code, Theme theme, StringBuilder builder) { - string[]? codeScopes = MarkdigTextMateScopeMapper.GetInlineScopes("CodeInline"); - (int cfg, int cbg, FontStyle cfStyle) = TokenProcessor.ExtractThemeProperties(new MarkdownToken(codeScopes), theme); - - // Apply the theme colors/style to the inline code - if (cfg != -1 || cbg != -1 || cfStyle != FontStyle.NotSet) { - Color codeColor = cfg != -1 ? StyleHelper.GetColor(cfg, theme) : Color.Default; - Color codeBgColor = cbg != -1 ? StyleHelper.GetColor(cbg, theme) : Color.Default; - Decoration codeDecoration = StyleHelper.GetDecoration(cfStyle); - - var codeStyle = new Style(codeColor, codeBgColor, codeDecoration); - builder.AppendWithStyle(codeStyle, code.Content); - } - else { - builder.Append(code.Content.EscapeMarkup()); - } - } -} diff --git a/src/Core/Markdown/MarkdownPipelines.cs b/src/Core/Markdown/MarkdownPipelines.cs deleted file mode 100644 index 82a8835..0000000 --- a/src/Core/Markdown/MarkdownPipelines.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Markdig; - -namespace PwshSpectreConsole.TextMate.Core.Markdown; - -/// -/// Provides reusable, pre-configured Markdown pipelines. -/// Pipelines are expensive to create (plugin registration), so they're cached statically. -/// -internal static class MarkdownPipelines { - /// - /// Standard pipeline with all common extensions enabled. - /// Suitable for most Markdown rendering tasks. - /// - public static readonly MarkdownPipeline Standard = BuildStandardPipeline(); - - private static MarkdownPipeline BuildStandardPipeline() { - return new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .UsePipeTables() - .UseEmphasisExtras() - .UseAutoLinks() - .UseTaskLists() - .EnableTrackTrivia() - .Build(); - } -} diff --git a/src/Core/Markdown/MarkdownRenderer.cs b/src/Core/Markdown/MarkdownRenderer.cs deleted file mode 100644 index 7312e20..0000000 --- a/src/Core/Markdown/MarkdownRenderer.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Markdig; -using System; -using System.IO; -using Markdig.Helpers; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown; - -/// -/// Markdown renderer that builds Spectre.Console objects directly instead of markup strings. -/// This eliminates VT escaping issues and avoids double-parsing overhead for better performance. -/// Supports both traditional switch-based rendering and visitor pattern for extensibility. -/// -internal static class MarkdownRenderer { - /// - /// Renders markdown content using the visitor pattern with extensible renderer collection. - /// Allows third-party extensions to add custom block renderers. - /// Wraps all blocks in a Rows renderable to ensure proper spacing between them. - /// - /// Markdown text (can be multi-line) - /// Theme object for styling - /// Theme name for TextMateProcessor - /// Optional custom renderer collection (uses default if null) - /// Single Rows renderable containing all markdown blocks - public static IRenderable RenderWithVisitorPattern( - string markdown, - Theme theme, - ThemeName themeName, - MarkdownRendererCollection? rendererCollection = null) { - - // Use cached pipeline for better performance - MarkdownDocument? document = Markdig.Markdown.Parse(markdown, MarkdownPipelines.Standard); - - // Create renderer collection if not provided - rendererCollection ??= new MarkdownRendererCollection(theme, themeName); - - var blocks = new List(); - - for (int i = 0; i < document.Count; i++) { - Block? block = document[i]; - - // Skip redundant paragraph that Markdig sometimes produces on the same line as a table - if (block is ParagraphBlock && i + 1 < document.Count) { - Block nextBlock = document[i + 1]; - if (nextBlock is Markdig.Extensions.Tables.Table table && block.Line == table.Line) { - continue; - } - } - - // Use visitor pattern to dispatch to appropriate renderer - IRenderable? renderable = rendererCollection.Render(block); - - if (renderable is not null) { - blocks.Add(renderable); - } - } - - // Wrap all blocks in Rows to ensure proper line breaks between them - return new Rows([.. blocks]); - } - - /// - /// Renders markdown content using Spectre.Console object building. - /// This approach eliminates VT escaping issues and improves performance. - /// Uses traditional switch-based dispatch for compatibility. - /// Wraps all blocks in a Rows renderable to ensure proper spacing between them. - /// - /// Markdown text (can be multi-line) - /// Theme object for styling - /// Theme name for TextMateProcessor - /// Single Rows renderable containing all markdown blocks with proper spacing - public static IRenderable Render(string markdown, Theme theme, ThemeName themeName) { - // Use cached pipeline for better performance - MarkdownDocument? document = Markdig.Markdown.Parse(markdown, MarkdownPipelines.Standard); - - var blocks = new List(); - Block? previousBlock = null; - - for (int i = 0; i < document.Count; i++) { - Block? block = document[i]; - - // Skip redundant paragraph that Markdig sometimes produces on the same line as a table - if (block is ParagraphBlock && i + 1 < document.Count) { - Block nextBlock = document[i + 1]; - if (nextBlock is Markdig.Extensions.Tables.Table table && block.Line == table.Line) { - continue; - } - } - - // Use block renderer that builds Spectre.Console objects directly - IRenderable? renderable = BlockRenderer.RenderBlock(block, theme, themeName); - - if (renderable is not null) { - // Preserve source gaps: add a single empty row when there is at least one blank line between blocks - if (previousBlock is not null) { - int gapFromTrivia = block.LinesBefore?.Count ?? 0; - int gapFromLines = block.Line - previousBlock.Line - 1; - int gap = Math.Max(gapFromTrivia, gapFromLines); - - if (gap > 0) { - blocks.Add(Text.Empty); - } - } - - blocks.Add(renderable); - previousBlock = block; - - // Add extra spacing after standalone images (sixel images need breathing room) - if (block is ParagraphBlock para && IsStandaloneImage(para)) { - blocks.Add(Text.Empty); - } - } - } - - // Wrap all blocks in Rows to ensure proper line breaks between them - return new Rows([.. blocks]); - } - - /// - /// Determines how many empty lines preceded this block in the source markdown. - /// Uses Markdig's trivia tracking (LinesBefore) which is enabled in our pipeline. - /// - private static int GetEmptyLinesBefore(Block block) { - // LinesBefore contains the empty lines that occurred before this block - // This is only populated when EnableTrackTrivia() is used in the pipeline - List? linesBefore = block.LinesBefore; - - // Don't add spacing before the first block - if (block.Line == 1) { - return 0; - } - - // If LinesBefore is populated, return the count (we'll add ONE Text.Empty per block) - return linesBefore?.Count ?? 0; - } - - /// - /// Checks if a paragraph block contains only a single image (no other text). - /// - private static bool IsStandaloneImage(ParagraphBlock paragraph) { - if (paragraph.Inline is null) { - return false; - } - - // Check if the paragraph contains only one LinkInline with IsImage = true - var inlines = paragraph.Inline.ToList(); - - // Single image case - if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { - return true; - } - - // Sometimes there might be whitespace inlines around the image - // Filter out empty/whitespace literals - var nonWhitespace = inlines - .Where(i => i is not LineBreakInline && !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) - .ToList(); - - return nonWhitespace.Count == 1 - && nonWhitespace[0] is LinkInline imageLink - && imageLink.IsImage; - } -} diff --git a/src/Core/Markdown/MarkdownRendererCollection.cs b/src/Core/Markdown/MarkdownRendererCollection.cs deleted file mode 100644 index e6814dd..0000000 --- a/src/Core/Markdown/MarkdownRendererCollection.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -namespace PwshSpectreConsole.TextMate.Core.Markdown; - -/// -/// Collection of markdown renderers implementing the visitor pattern. -/// Dispatches markdown objects to appropriate renderers based on type. -/// -internal class MarkdownRendererCollection { - private readonly List _renderers = []; - - /// - /// Initializes the renderer collection with all standard block renderers. - /// - public MarkdownRendererCollection(Theme theme, ThemeName themeName) { - // Register standard block renderers - _renderers.Add(new SpectreHeadingRenderer(theme, themeName)); - _renderers.Add(new SpectreParagraphRenderer(theme, themeName)); - _renderers.Add(new SpectreCodeBlockRenderer(theme, themeName)); - _renderers.Add(new SpectreQuoteRenderer(theme, themeName)); - _renderers.Add(new SpectreListRenderer(theme, themeName)); - _renderers.Add(new SpectreTableRenderer(theme, themeName)); - _renderers.Add(new SpectreHtmlBlockRenderer(theme, themeName)); - _renderers.Add(new SpectreHorizontalRuleRenderer()); - } - - /// - /// Finds and uses the appropriate renderer for a markdown object. - /// - /// Rendered object, or null if no renderer found - public IRenderable? Render(MarkdownObject obj) { - if (obj is null) return null; - - // Find first renderer that accepts this object type - ISpectreMarkdownRenderer? renderer = _renderers.FirstOrDefault(r => r.Accept(obj.GetType())); - - if (renderer is not null) return renderer.Render(obj); - - // Log unmapped type for debugging - // System.Diagnostics.Debug.WriteLine( - // $"No renderer registered for type {obj.GetType().Name}"); - - return null; - } - - /// - /// Adds a custom renderer to the collection. - /// Allows third-party extensions to add support for new block types. - /// - public void Add(ISpectreMarkdownRenderer renderer) { - if (renderer != null) - _renderers.Add(renderer); - } -} diff --git a/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs b/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs deleted file mode 100644 index c3d2ded..0000000 --- a/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System.Buffers; -using System.Text; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Optimizations; - -/// -/// Provides span-optimized operations for markdown validation and input processing. -/// Reduces allocations during text analysis and validation operations. -/// -internal static class SpanOptimizedMarkdownProcessor { - private static readonly SearchValues LineBreakChars = SearchValues.Create(['\r', '\n']); - // private static readonly SearchValues WhitespaceChars = SearchValues.Create([' ', '\t', '\r', '\n']); - - /// - /// Counts lines in markdown text using span operations for better performance. - /// - /// Markdown text to analyze - /// Number of lines - public static int CountLinesOptimized(ReadOnlySpan markdown) { - if (markdown.IsEmpty) return 0; - - int lineCount = 1; // Start with 1 for the first line - int index = 0; - - while ((index = markdown[index..].IndexOfAny(LineBreakChars)) >= 0) { - // Handle CRLF as single line break - if (index < markdown.Length - 1 && - markdown[index] == '\r' && - markdown[index + 1] == '\n') { - index += 2; - } - else { - index++; - } - - lineCount++; - - if (index >= markdown.Length) break; - } - - return lineCount; - } - - /// - /// Splits markdown into lines using span operations and returns string array. - /// Optimized to minimize allocations during the splitting process. - /// - /// Markdown text to split - /// Array of line strings - public static string[] SplitIntoLinesOptimized(ReadOnlySpan markdown) { - if (markdown.IsEmpty) return []; - - int lineCount = CountLinesOptimized(markdown); - string[]? lines = new string[lineCount]; - int lineIndex = 0; - int start = 0; - - for (int i = 0; i < markdown.Length; i++) { - bool isLineBreak = markdown[i] is '\r' or '\n'; - - if (isLineBreak) { - lines[lineIndex++] = markdown[start..i].ToString(); - - // Handle CRLF - if (i < markdown.Length - 1 && markdown[i] == '\r' && markdown[i + 1] == '\n') - i++; // Skip the \n in \r\n - - start = i + 1; - } - } - - // Add the last line if it doesn't end with a line break - if (start < markdown.Length && lineIndex < lines.Length) - lines[lineIndex] = markdown[start..].ToString(); - - return lines; - } - - /// - /// Finds the maximum line length using span operations. - /// - /// Markdown text to analyze - /// Maximum line length - public static int FindMaxLineLengthOptimized(ReadOnlySpan markdown) { - if (markdown.IsEmpty) return 0; - - int maxLength = 0; - int currentLength = 0; - - foreach (char c in markdown) { - if (c is '\r' or '\n') { - maxLength = Math.Max(maxLength, currentLength); - currentLength = 0; - } - else { - currentLength++; - } - } - - // Check the last line - return Math.Max(maxLength, currentLength); - } - - /// - /// Efficiently trims whitespace from multiple lines using spans. - /// - /// Array of line strings - /// Array of trimmed lines - public static string[] TrimLinesOptimized(string[] lines) { - string[]? trimmedLines = new string[lines.Length]; - - for (int i = 0; i < lines.Length; i++) { - if (string.IsNullOrEmpty(lines[i])) { - trimmedLines[i] = string.Empty; - continue; - } - - ReadOnlySpan trimmed = lines[i].AsSpan().Trim(); - trimmedLines[i] = trimmed.Length == lines[i].Length ? lines[i] : trimmed.ToString(); - } - - return trimmedLines; - } - - /// - /// Joins lines back into markdown using span-optimized operations. - /// - /// Lines to join - /// Line ending to use (default: \n) - /// Joined markdown text - public static string JoinLinesOptimized(ReadOnlySpan lines, ReadOnlySpan lineEnding = default) { - if (lines.IsEmpty) return string.Empty; - if (lines.Length == 1) return lines[0] ?? string.Empty; - - ReadOnlySpan ending = lineEnding.IsEmpty ? "\n".AsSpan() : lineEnding; - - // Calculate total capacity - int totalLength = (lines.Length - 1) * ending.Length; - foreach (string line in lines) - totalLength += line?.Length ?? 0; - - var builder = new StringBuilder(totalLength); - - for (int i = 0; i < lines.Length; i++) { - if (i > 0) builder.Append(ending); - if (lines[i] is not null) - builder.Append(lines[i].AsSpan()); - } - - return builder.ToString(); - } - - /// - /// Removes empty lines efficiently using span operations. - /// - /// Lines to filter - /// Array with empty lines removed - public static string[] RemoveEmptyLinesOptimized(string[] lines) { - // First pass: count non-empty lines - int nonEmptyCount = 0; - foreach (string line in lines) { - if (!string.IsNullOrEmpty(line) && !line.AsSpan().Trim().IsEmpty) - nonEmptyCount++; - } - - if (nonEmptyCount == lines.Length) return lines; // No empty lines - if (nonEmptyCount == 0) return []; // All empty - - // Second pass: copy non-empty lines - string[]? result = new string[nonEmptyCount]; - int index = 0; - - foreach (string line in lines) { - if (!string.IsNullOrEmpty(line) && !line.AsSpan().Trim().IsEmpty) - result[index++] = line; - } - - return result; - } - - /// - /// Counts specific characters in markdown using span operations. - /// - /// Markdown text to analyze - /// Character to count - /// Number of occurrences - public static int CountCharacterOptimized(ReadOnlySpan markdown, char targetChar) { - if (markdown.IsEmpty) return 0; - - int count = 0; - int index = 0; - - while ((index = markdown[index..].IndexOf(targetChar)) >= 0) { - count++; - index++; - if (index >= markdown.Length) break; - } - - return count; - } -} diff --git a/src/Core/Markdown/README.md b/src/Core/Markdown/README.md deleted file mode 100644 index 3e303d9..0000000 --- a/src/Core/Markdown/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# Markdown Renderer Architecture - -This document describes the refactored markdown rendering architecture that replaced the monolithic `MarkdigSpectreMarkdownRenderer` class. - -## Overview - -The markdown rendering functionality has been split into focused, single-responsibility components organized in the `Core/Markdown` folder structure for better maintainability and testing. - -## Folder Structure - -```note -src/Core/Markdown/ -├── MarkdownRenderer.cs # Main orchestrator -├── InlineProcessor.cs # Inline element processing -└── Renderers/ # Block-specific renderers - ├── BlockRenderer.cs # Main dispatcher - ├── HeadingRenderer.cs # Heading blocks - ├── ParagraphRenderer.cs # Paragraph blocks - ├── ListRenderer.cs # List and task list blocks - ├── CodeBlockRenderer.cs # Fenced/indented code blocks - ├── TableRenderer.cs # Table blocks - ├── QuoteRenderer.cs # Quote blocks - ├── HtmlBlockRenderer.cs # HTML blocks - └── HorizontalRuleRenderer.cs # Horizontal rules -``` - -## Component Responsibilities - -### MarkdownRenderer - -- **Purpose**: Main entry point for markdown rendering -- **Responsibilities**: - - Creates Markdig pipeline with extensions - - Parses markdown document - - Orchestrates block rendering - - Manages spacing between elements - -### InlineProcessor - -- **Purpose**: Handles all inline markdown elements -- **Responsibilities**: - - Processes inline text extraction - - Handles emphasis (bold/italic) - - Processes links and images - - Manages inline code styling - - Applies theme-based styling - -### BlockRenderer - -- **Purpose**: Dispatches block elements to specific renderers -- **Responsibilities**: - - Pattern matches block types - - Routes to appropriate specialized renderer - - Maintains clean separation of concerns - -### Specialized Renderers - -Each renderer handles a specific block type with focused responsibilities: - -- **HeadingRenderer**: H1-H6 headings with theme-aware styling -- **ParagraphRenderer**: Text paragraphs with inline processing -- **ListRenderer**: Ordered/unordered lists and task lists with checkbox support -- **CodeBlockRenderer**: Syntax-highlighted code blocks (fenced and indented) -- **TableRenderer**: Complex table rendering with headers and data rows -- **QuoteRenderer**: Blockquotes with bordered panels -- **HtmlBlockRenderer**: Raw HTML blocks with syntax highlighting -- **HorizontalRuleRenderer**: Thematic breaks and horizontal rules - -## Key Features - -### Task List Support - -- Detects `[x]`, `[X]`, and `[ ]` checkbox syntax -- Renders with Unicode checkbox characters (☑️, ☐) -- Automatically strips checkbox markup from displayed text - -### Theme Integration - -- Full TextMate theme support across all elements -- Consistent color and styling application -- Fallback styling for unsupported elements - -### Performance Optimizations - -- StringBuilder usage for efficient text building -- Batch processing where possible -- Minimal object allocation -- Escape markup handling optimized per context - -### Image Handling - -- Special image link rendering with emoji indicators -- Styled image descriptions -- URL display for accessibility - -### Code Highlighting - -- TextMateProcessor integration for syntax highlighting -- Language-specific panels with headers -- Fallback rendering for unsupported languages -- Proper markup escaping in code blocks - -## Migration Notes - -### Backward Compatibility - -The original `MarkdigSpectreMarkdownRenderer` class remains as a legacy wrapper that delegates to the new implementation, ensuring existing code continues to work without changes. - -### Usage - -```csharp -// New way (recommended) -var result = MarkdownRenderer.Render(markdown, theme, themeName); - -// Old way (still works via delegation) -var result = MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); -``` - -## Benefits of Refactoring - -1. **Maintainability**: Each component has a single responsibility -2. **Testability**: Individual renderers can be unit tested in isolation -3. **Extensibility**: New block types can be added without modifying existing code -4. **Readability**: Clear separation of concerns makes code easier to understand -5. **Performance**: Optimized processing paths for different element types -6. **Debugging**: Issues can be isolated to specific renderer components - -## Future Enhancements - -The modular architecture makes it easy to add: - -- Custom block renderers -- Additional inline element processors -- Enhanced theme customization -- Performance monitoring per renderer -- Caching strategies per component type diff --git a/src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs b/src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs deleted file mode 100644 index 9c4d61e..0000000 --- a/src/Core/Markdown/Renderers/ISpectreMarkdownRenderer.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Base interface for Spectre.Console markdown object renderers. -/// Follows the visitor pattern for extensible rendering. -/// -public interface ISpectreMarkdownRenderer { - /// - /// Determines if this renderer handles the given object type. - /// - bool Accept(Type objectType); - - /// - /// Renders a markdown object to a Spectre renderable. - /// - IRenderable Render(MarkdownObject obj); -} diff --git a/src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs b/src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs deleted file mode 100644 index dfd9091..0000000 --- a/src/Core/Markdown/Renderers/SpectreCodeBlockRenderer.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for Markdown code blocks (fenced and indented). -/// -internal class SpectreCodeBlockRenderer : SpectreMarkdownObjectRenderer { - private readonly Theme _theme; - private readonly ThemeName _themeName; - - public SpectreCodeBlockRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(CodeBlock codeBlock) { - // CodeBlockRenderer has separate methods for FencedCodeBlock and regular CodeBlock - return codeBlock is FencedCodeBlock fenced - ? CodeBlockRenderer.RenderFencedCodeBlock(fenced, _theme, _themeName) - : CodeBlockRenderer.RenderCodeBlock(codeBlock, _theme); - } -} diff --git a/src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs b/src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs deleted file mode 100644 index 2729bb6..0000000 --- a/src/Core/Markdown/Renderers/SpectreHeadingRenderer.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for Markdown heading blocks. -/// Renders headings with level-based styling using TextMate themes. -/// -internal class SpectreHeadingRenderer : SpectreMarkdownObjectRenderer { - private readonly Theme _theme; - private readonly ThemeName _themeName; - public SpectreHeadingRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(HeadingBlock heading) => - // Delegate to existing static implementation for now - // This maintains compatibility while adopting visitor pattern - HeadingRenderer.Render(heading, _theme); -} diff --git a/src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs b/src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs deleted file mode 100644 index 17cfe20..0000000 --- a/src/Core/Markdown/Renderers/SpectreHorizontalRuleRenderer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; -using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Renders thematic break blocks (horizontal rules) using the visitor pattern. -/// -internal class SpectreHorizontalRuleRenderer : SpectreMarkdownObjectRenderer { - public SpectreHorizontalRuleRenderer() { - } - - protected override IRenderable Render(ThematicBreakBlock block) - => HorizontalRuleRenderer.Render(); -} diff --git a/src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs b/src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs deleted file mode 100644 index d542d1d..0000000 --- a/src/Core/Markdown/Renderers/SpectreHtmlBlockRenderer.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for HTML blocks in Markdown. -/// -internal class SpectreHtmlBlockRenderer : SpectreMarkdownObjectRenderer { - private readonly Theme _theme; - private readonly ThemeName _themeName; - - public SpectreHtmlBlockRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(HtmlBlock htmlBlock) - => HtmlBlockRenderer.Render(htmlBlock, _theme, _themeName); -} diff --git a/src/Core/Markdown/Renderers/SpectreListRenderer.cs b/src/Core/Markdown/Renderers/SpectreListRenderer.cs deleted file mode 100644 index d6576be..0000000 --- a/src/Core/Markdown/Renderers/SpectreListRenderer.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for Markdown list blocks (ordered and unordered). -/// -internal class SpectreListRenderer : SpectreMarkdownObjectRenderer { - private readonly Theme _theme; - private readonly ThemeName _themeName; - public SpectreListRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(ListBlock list) - => ListRenderer.Render(list, _theme); -} diff --git a/src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs b/src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs deleted file mode 100644 index b39d82d..0000000 --- a/src/Core/Markdown/Renderers/SpectreMarkdownObjectRenderer.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Generic base class for markdown object renderers. -/// Implements type checking and dispatch logic. -/// Derived classes need only implement the type-specific Render method. -/// -public abstract class SpectreMarkdownObjectRenderer - : ISpectreMarkdownRenderer - where TObject : MarkdownObject { - /// - /// Checks if this renderer accepts the given object type. - /// - public virtual bool Accept(Type objectType) - => typeof(TObject).IsAssignableFrom(objectType); - - /// - /// Renders a markdown object (dispatches to typed method). - /// - public IRenderable Render(MarkdownObject obj) - => Render((TObject)obj); - - /// - /// Override this method to implement type-specific rendering. - /// - protected abstract IRenderable Render(TObject obj); -} diff --git a/src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs b/src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs deleted file mode 100644 index b86a7e1..0000000 --- a/src/Core/Markdown/Renderers/SpectreParagraphRenderer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for Markdown paragraph blocks. -/// -internal class SpectreParagraphRenderer : SpectreMarkdownObjectRenderer { - private readonly Theme _theme; - private readonly ThemeName _themeName; - - public SpectreParagraphRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(ParagraphBlock paragraph) - => ParagraphRenderer.Render(paragraph, _theme); -} diff --git a/src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs b/src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs deleted file mode 100644 index aa4fac5..0000000 --- a/src/Core/Markdown/Renderers/SpectreQuoteRenderer.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Markdig.Syntax; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for Markdown quote blocks. -/// -internal class SpectreQuoteRenderer : SpectreMarkdownObjectRenderer { - private readonly Theme _theme; - private readonly ThemeName _themeName; - - public SpectreQuoteRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(QuoteBlock quote) => QuoteRenderer.Render(quote, _theme); -} diff --git a/src/Core/Markdown/Renderers/SpectreTableRenderer.cs b/src/Core/Markdown/Renderers/SpectreTableRenderer.cs deleted file mode 100644 index db40a7e..0000000 --- a/src/Core/Markdown/Renderers/SpectreTableRenderer.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Markdig.Extensions.Tables; -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; - -/// -/// Visitor-pattern renderer for Markdown table blocks. -/// -internal class SpectreTableRenderer : SpectreMarkdownObjectRenderer
{ - private readonly Theme _theme; - private readonly ThemeName _themeName; - - public SpectreTableRenderer(Theme theme, ThemeName themeName) { - _theme = theme; - _themeName = themeName; - } - - protected override IRenderable Render(Table table) => TableRenderer.Render(table, _theme)!; -} diff --git a/src/Core/Markdown/Types/MarkdownTypes.cs b/src/Core/Markdown/Types/MarkdownTypes.cs deleted file mode 100644 index 7e09611..0000000 --- a/src/Core/Markdown/Types/MarkdownTypes.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Types; - -/// -/// Represents the result of rendering a markdown block element. -/// Provides type safety and better error handling for rendering operations. -/// -public sealed record MarkdownRenderResult { - /// - /// The rendered element that can be displayed by Spectre.Console. - /// - public IRenderable? Renderable { get; init; } - - /// - /// Indicates whether the rendering was successful. - /// - public bool Success { get; init; } - - /// - /// Error message if rendering failed. - /// - public string? ErrorMessage { get; init; } - - /// - /// The type of markdown block that was processed. - /// - public MarkdownBlockType BlockType { get; init; } - - /// - /// Creates a successful render result. - /// - public static MarkdownRenderResult CreateSuccess(IRenderable renderable, MarkdownBlockType blockType) => - new() { Renderable = renderable, Success = true, BlockType = blockType }; - - /// - /// Creates a failed render result. - /// - public static MarkdownRenderResult CreateFailure(string errorMessage, MarkdownBlockType blockType) => - new() { Success = false, ErrorMessage = errorMessage, BlockType = blockType }; - - /// - /// Creates a result for unsupported block types. - /// - public static MarkdownRenderResult CreateUnsupported(MarkdownBlockType blockType) => - new() { Success = false, ErrorMessage = $"Block type '{blockType}' is not supported", BlockType = blockType }; -} - -/// -/// Enumeration of supported markdown block types for better type safety. -/// -public enum MarkdownBlockType { - /// Unknown or unrecognized block type. - Unknown, - /// Heading block (h1-h6). - Heading, - /// Paragraph block with inline content. - Paragraph, - /// List block (ordered or unordered). - List, - /// Fenced code block with syntax highlighting. - FencedCodeBlock, - /// Indented code block. - CodeBlock, - /// Table block with cells and rows. - Table, - /// Block quote or indented text. - Quote, - /// Raw HTML block (sanitized for security). - HtmlBlock, - /// Thematic break or horizontal rule. - ThematicBreak, - /// Task list with checkboxes. - TaskList -} - -/// -/// Configuration options for markdown rendering with validation. -/// -public sealed record MarkdownRenderOptions { - /// - /// The theme to use for rendering. - /// - public required Theme Theme { get; init; } - - /// - /// The theme name for TextMate processing. - /// - public required ThemeName ThemeName { get; init; } - - /// - /// Whether to enable debug output. - /// - public bool EnableDebug { get; init; } - - /// - /// Maximum rendering depth to prevent stack overflow. - /// - public int MaxRenderingDepth { get; init; } = 100; - - /// - /// Whether to add spacing between block elements. - /// - public bool AddBlockSpacing { get; init; } = true; - - /// - /// Validates the render options. - /// - public void Validate() { - if (MaxRenderingDepth <= 0) - throw new ArgumentException("MaxRenderingDepth must be greater than 0", nameof(MaxRenderingDepth)); - } -} - -/// -/// Represents inline rendering context with type safety. -/// -public sealed record InlineRenderContext { - /// - /// The theme for styling. - /// - public required Theme Theme { get; init; } - - /// - /// Current nesting depth. - /// - public int Depth { get; init; } - - /// - /// Whether markup escaping is enabled. - /// - public bool EscapeMarkup { get; init; } = true; - - /// - /// Creates a new context with incremented depth. - /// - public InlineRenderContext WithIncrementedDepth() => this with { Depth = Depth + 1 }; - - /// - /// Creates a new context with disabled markup escaping. - /// - public InlineRenderContext WithoutMarkupEscaping() => this with { EscapeMarkup = false }; -} diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index 0c41c3e..f7f6e6f 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -1,31 +1,24 @@ -using PwshSpectreConsole.TextMate.Core.Markdown; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// -/// Provides specialized rendering for Markdown content using the modern Markdig-based renderer. -/// This facade delegates to the Core.Markdown.MarkdownRenderer which builds Spectre.Console objects directly. +/// Facade for markdown rendering that adapts between TextMateProcessor's interface +/// and the Markdig-based renderer in PSTextMate.Rendering. /// -/// -/// Legacy string-based renderer was removed in favor of the object-based Markdig renderer for better performance -/// and to eliminate VT escape sequence issues. -/// internal static class MarkdownRenderer { /// - /// Renders Markdown content with special handling for links and enhanced formatting. + /// Renders markdown content with compatibility layer for TextMateProcessor. /// - /// Lines to render - /// Theme to apply - /// Markdown grammar (used for compatibility, actual rendering uses Markdig) - /// Theme name for passing to Markdig renderer - /// Optional debug callback (not used by Markdig renderer) - /// Rendered rows with markdown syntax highlighting - public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName, Action? debugCallback) { + /// Markdown lines to render + /// Theme for syntax highlighting + /// Grammar (unused, maintained for interface compatibility) + /// Theme name enumeration + /// Rendered markdown as IRenderable array + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName) { string markdown = string.Join('\n', lines); - IRenderable result = Markdown.MarkdownRenderer.Render(markdown, theme, themeName); - return [result]; + return Rendering.MarkdownRenderer.Render(markdown, theme, themeName); } } diff --git a/src/Core/MarkdownToken.cs b/src/Core/MarkdownToken.cs index 7eb02b2..d5a5eca 100644 --- a/src/Core/MarkdownToken.cs +++ b/src/Core/MarkdownToken.cs @@ -1,6 +1,6 @@ using TextMateSharp.Grammars; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Simple token for theme lookup from a set of scopes (for markdown elements). diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 3ececc8..92eef68 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -1,11 +1,11 @@ using System.Text; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Provides optimized rendering for standard (non-Markdown) TextMate grammars. @@ -20,9 +20,9 @@ internal static class StandardRenderer { /// Theme to apply /// Grammar for tokenization /// Rendered rows with syntax highlighting - public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar, null); + // public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar); - public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) { + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) { StringBuilder builder = StringBuilderPool.Rent(); List rows = new(lines.Length); @@ -32,7 +32,7 @@ public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar string line = lines[lineIndex]; ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); ruleStack = result.RuleStack; - TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, debugCallback, lineIndex); + TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, lineIndex); string? lineMarkup = builder.ToString(); rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); builder.Clear(); diff --git a/src/Core/StyleHelper.cs b/src/Core/StyleHelper.cs index 3e6aa76..2ff5602 100644 --- a/src/Core/StyleHelper.cs +++ b/src/Core/StyleHelper.cs @@ -1,7 +1,7 @@ using Spectre.Console; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Provides utility methods for style and color conversion operations. diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 7ba7454..4b76927 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -1,36 +1,18 @@ using System.Text; -using PwshSpectreConsole.TextMate.Extensions; -using PwshSpectreConsole.TextMate.Helpers; -using PwshSpectreConsole.TextMate.Infrastructure; +using PSTextMate.Utilities; +using PSTextMate.Core; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Main entry point for TextMate processing operations. /// Provides high-performance text processing using TextMate grammars and themes. /// public static class TextMateProcessor { - /// - /// Processes string lines with specified theme and grammar for syntax highlighting. - /// This is the unified method that handles all text processing scenarios. - /// - /// Array of text lines to process - /// Theme to apply for styling - /// Language ID or file extension for grammar selection - /// True if grammarId is a file extension, false if it's a language ID - /// Rendered rows with syntax highlighting, or null if processing fails - /// Thrown when is null - /// Thrown when grammar cannot be found or processing encounters an error - public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { - ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - - return lines.Length == 0 || lines.AllIsNullOrEmpty() ? null : ProcessLines(lines, themeName, grammarId, isExtension, null); - } - /// /// Processes string lines for code blocks without escaping markup characters. /// This preserves raw source code content for proper syntax highlighting. @@ -39,11 +21,10 @@ public static class TextMateProcessor { /// Theme to apply for styling /// Language ID or file extension for grammar selection /// True if grammarId is a file extension, false if it's a language ID - /// Optional callback for debugging token information /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, Action? debugCallback) { + public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); if (lines.Length == 0 || lines.AllIsNullOrEmpty()) { @@ -57,8 +38,8 @@ public static class TextMateProcessor { // Use optimized rendering based on grammar type return grammar.GetName() == "Markdown" - ? MarkdownRenderer.Render(lines, theme, grammar, themeName, debugCallback) - : StandardRenderer.Render(lines, theme, grammar, debugCallback); + ? MarkdownRenderer.Render(lines, theme, grammar, themeName) + : StandardRenderer.Render(lines, theme, grammar); } catch (InvalidOperationException) { throw; @@ -123,7 +104,7 @@ private static IRenderable[] RenderCodeBlock(string[] lines, Theme theme, IGramm string line = lines[lineIndex]; ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); ruleStack = result.RuleStack; - TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, debugCallback: null, lineIndex, escapeMarkup: false); + TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, lineIndex, escapeMarkup: false); string lineMarkup = builder.ToString(); // Use Markup to parse the color codes generated by TextMateProcessor // If markup is empty, use an empty Text object instead @@ -191,8 +172,8 @@ public static IEnumerable ProcessLinesInBatches( if (buffer.Count >= batchSize) { // Render the batch using the already-loaded grammar and theme IRenderable[]? result = useMarkdownRenderer - ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) - : StandardRenderer.Render([.. buffer], theme, grammar, null); + ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName) + : StandardRenderer.Render([.. buffer], theme, grammar); if (result is not null) { yield return new HighlightedText { @@ -213,8 +194,8 @@ public static IEnumerable ProcessLinesInBatches( cancellationToken.ThrowIfCancellationRequested(); IRenderable[]? result = useMarkdownRenderer - ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName, null) - : StandardRenderer.Render([.. buffer], theme, grammar, null); + ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName) + : StandardRenderer.Render([.. buffer], theme, grammar); if (result is not null) { yield return new HighlightedText { diff --git a/src/Core/TokenDebugInfo.cs b/src/Core/TokenDebugInfo.cs deleted file mode 100644 index 34d8a68..0000000 --- a/src/Core/TokenDebugInfo.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.ObjectModel; -using Spectre.Console; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// Contains debug information for a single parsed token including position, scope, and styling details. -/// -public class TokenDebugInfo { - /// - /// Line number of this token (zero-based index). - /// - public int? LineIndex { get; set; } - /// - /// Starting character position of this token in the line. - /// - public int StartIndex { get; set; } - /// - /// Ending character position of this token in the line. - /// - public int EndIndex { get; set; } - /// - /// The actual text content of this token. - /// - public string? Text { get; set; } - /// - /// List of scopes that apply to this token (for theme matching). - /// - public List? Scopes { get; set; } - /// - /// Foreground color ID from theme (negative if not set). - /// - public int Foreground { get; set; } - /// - /// Background color ID from theme (negative if not set). - /// - public int Background { get; set; } - /// - /// Font style applied to this token (bold, italic, underline). - /// - public FontStyle FontStyle { get; set; } - /// - /// Resolved Spectre.Console style for rendering this token. - /// - public Style? Style { get; set; } - /// - /// Theme color dictionary used for rendering this token. - /// - public ReadOnlyDictionary? Theme { get; set; } -} diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index fb5276d..d28bdc4 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -1,12 +1,12 @@ using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text; -using PwshSpectreConsole.TextMate.Extensions; +using PSTextMate.Utilities; using Spectre.Console; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Provides optimized token processing and styling operations. @@ -29,7 +29,6 @@ internal static class TokenProcessor { /// Source line text /// Theme for styling /// StringBuilder for output - /// Optional callback for debugging /// Line index for debugging context /// Whether to escape markup characters (true for normal text, false for code blocks) public static void ProcessTokensBatch( @@ -37,7 +36,6 @@ public static void ProcessTokensBatch( string line, Theme theme, StringBuilder builder, - Action? debugCallback = null, int? lineIndex = null, bool escapeMarkup = true) { foreach (IToken token in tokens) { @@ -53,26 +51,11 @@ public static void ProcessTokensBatch( // Only extract numeric theme properties when debugging is enabled to reduce work (int foreground, int background, FontStyle fontStyle) = (-1, -1, FontStyle.NotSet); - if (debugCallback is not null) { - (foreground, background, fontStyle) = ExtractThemeProperties(token, theme); - } // Use the returning API so callers can append with style consistently (prevents markup regressions) (string processedText, Style? resolvedStyle) = WriteTokenOptimizedReturn(textSpan, style, theme, escapeMarkup); builder.AppendWithStyle(resolvedStyle, processedText); - debugCallback?.Invoke(new TokenDebugInfo { - LineIndex = lineIndex, - StartIndex = startIndex, - EndIndex = endIndex, - Text = line.SubstringAtIndexes(startIndex, endIndex), - Scopes = token.Scopes, - Foreground = foreground, - Background = background, - FontStyle = fontStyle, - Style = style, - Theme = theme.GetGuiColorDictionary() - }); } } diff --git a/src/Core/Validation/MarkdownInputValidator.cs b/src/Core/Validation/MarkdownInputValidator.cs deleted file mode 100644 index ae973ca..0000000 --- a/src/Core/Validation/MarkdownInputValidator.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Core.Validation; - -/// -/// Provides validation utilities for markdown input and rendering parameters. -/// Helps prevent security issues and improves error handling. -/// -internal static partial class MarkdownInputValidator { - private const int MaxMarkdownLength = 1_000_000; // 1MB text limit - private const int MaxLineCount = 10_000; - private const int MaxLineLength = 50_000; - - [GeneratedRegex(@")<[^<]*)*<\/script>", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex ScriptTagRegex(); - - [GeneratedRegex(@"javascript:|data:|vbscript:", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex DangerousUrlRegex(); - - /// - /// Validates markdown input for security and size constraints. - /// - /// The markdown text to validate - /// Validation result with any errors - public static ValidationResult ValidateMarkdownInput(string? markdown) { - if (string.IsNullOrEmpty(markdown)) - return ValidationResult.Success!; - - var errors = new List(); - - // Check size limits - if (markdown.Length > MaxMarkdownLength) - errors.Add($"Markdown content exceeds maximum length of {MaxMarkdownLength:N0} characters"); - - string[] lines = markdown.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - if (lines.Length > MaxLineCount) - errors.Add($"Markdown content exceeds maximum line count of {MaxLineCount:N0}"); - - foreach (string line in lines) { - if (line.Length > MaxLineLength) { - errors.Add($"Line exceeds maximum length of {MaxLineLength:N0} characters"); - break; - } - } - - // Check for potentially dangerous content - if (ScriptTagRegex().IsMatch(markdown)) - errors.Add("Markdown contains potentially dangerous script tags"); - - // Check for dangerous URLs in links - if (DangerousUrlRegex().IsMatch(markdown)) - errors.Add("Markdown contains potentially dangerous URLs"); - - return errors.Count > 0 - ? new ValidationResult(string.Join("; ", errors)) - : ValidationResult.Success!; - } - - /// - /// Validates theme name parameter. - /// - /// The theme name to validate - /// True if valid, false otherwise - public static bool IsValidThemeName(ThemeName themeName) => Enum.IsDefined(typeof(ThemeName), themeName); - - /// - /// Sanitizes URL input for link rendering. - /// - /// The URL to sanitize - /// Sanitized URL or null if dangerous - public static string? SanitizeUrl(string? url) { - if (string.IsNullOrWhiteSpace(url)) - return null; - - // Remove dangerous protocols - if (DangerousUrlRegex().IsMatch(url)) - return null; - - // Basic URL validation - return !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) && - !Uri.TryCreate(url, UriKind.Relative, out uri) - ? null - : url.Trim(); - } - - /// - /// Validates language identifier for syntax highlighting. - /// - /// The language identifier - /// True if supported, false otherwise - public static bool IsValidLanguage(string? language) { - if (string.IsNullOrWhiteSpace(language)) - return false; - - // Check against known supported languages - return TextMateLanguages.IsSupportedLanguage(language); - } -} diff --git a/src/Helpers/Debug.cs b/src/Helpers/Debug.cs deleted file mode 100644 index 3b7cdc1..0000000 --- a/src/Helpers/Debug.cs +++ /dev/null @@ -1,111 +0,0 @@ - -using System.Collections.ObjectModel; -using Spectre.Console; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -// this is just for debugging purposes. - -namespace PwshSpectreConsole.TextMate; - -/// -/// Provides debugging utilities for TextMate processing operations. -/// -public static class Test { - /// - /// Debug information wrapper for TextMate token and styling data. - /// - public class TextMateDebug { - /// - /// Line number of the token (zero-based). - /// - public int? LineIndex { get; set; } - /// - /// Starting character position of the token. - /// - public int StartIndex { get; set; } - /// - /// Ending character position of the token. - /// - public int EndIndex { get; set; } - /// - /// Text content of the token. - /// - public string? Text { get; set; } - /// - /// Scopes applying to this token for theme matching. - /// - public List? Scopes { get; set; } - /// - /// Foreground color ID from the theme. - /// - public int Foreground { get; set; } - /// - /// Background color ID from the theme. - /// - public int Background { get; set; } - /// - /// Font style flags (bold, italic, underline). - /// - public FontStyle FontStyle { get; set; } - /// - /// Resolved Spectre.Console style for rendering. - /// - public Style? Style { get; set; } - /// - /// Theme color dictionary used for rendering. - /// - public ReadOnlyDictionary? Theme { get; set; } - } - - /// - /// Debugs TextMate processing and returns styled token information. - /// - /// Text lines to debug - /// Theme to apply - /// Grammar language ID or file extension - /// True if grammarId is a file extension, false for language ID - /// Array of debug information for all tokens - public static TextMateDebug[]? DebugTextMate(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) { - var debugList = new List(); - Core.TextMateProcessor.ProcessLines( - lines, - themeName, - grammarId, - isExtension: FromFile, - debugCallback: info => debugList.Add(new TextMateDebug { - LineIndex = info.LineIndex, - StartIndex = info.StartIndex, - EndIndex = info.EndIndex, - Text = info.Text, - Scopes = info.Scopes, - Foreground = info.Foreground, - Background = info.Background, - FontStyle = info.FontStyle, - Style = info.Style, - Theme = info.Theme - }) - ); - return [.. debugList]; - } - - /// - /// Returns detailed debug information for each token with styling applied. - /// - /// Text lines to debug - /// Theme to apply - /// Grammar language ID or file extension - /// True if grammarId is a file extension, false for language ID - /// Array of token debug information - public static Core.TokenDebugInfo[]? DebugTextMateTokens(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) { - var debugList = new List(); - Core.TextMateProcessor.ProcessLines( - lines, - themeName, - grammarId, - isExtension: FromFile, - debugCallback: debugList.Add - ); - return [.. debugList]; - } -} diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index 6c620ef..da8c07a 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Core/Markdown/Renderers/BlockRenderer.cs b/src/Rendering/BlockRenderer.cs similarity index 57% rename from src/Core/Markdown/Renderers/BlockRenderer.cs rename to src/Rendering/BlockRenderer.cs index 514ff82..7182e97 100644 --- a/src/Core/Markdown/Renderers/BlockRenderer.cs +++ b/src/Rendering/BlockRenderer.cs @@ -1,11 +1,12 @@ -using Markdig.Extensions.Tables; +using Markdig.Extensions.Tables; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using PSTextMate.Utilities; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Block renderer that uses Spectre.Console object building instead of markup strings. @@ -19,57 +20,40 @@ internal static class BlockRenderer { /// The block element to render /// Theme for styling /// Theme name for TextMateProcessor - /// Rendered block as a Spectre.Console object, or null if unsupported - public static IRenderable? RenderBlock(Block block, Theme theme, ThemeName themeName) { + /// Enumerable of rendered items (each block produces one or more renderables) + public static IEnumerable RenderBlock(Block block, Theme theme, ThemeName themeName) { return block switch { // Special handling for paragraphs that contain only an image - ParagraphBlock paragraph when IsStandaloneImage(paragraph) => RenderStandaloneImage(paragraph, theme), + ParagraphBlock paragraph when MarkdownPatterns.IsStandaloneImage(paragraph) + => RenderStandaloneImage(paragraph, theme) is IRenderable r ? new[] { r } : [], // Use renderers that build Spectre.Console objects directly - HeadingBlock heading => HeadingRenderer.Render(heading, theme), - ParagraphBlock paragraph => ParagraphRenderer.Render(paragraph, theme), - ListBlock list => ListRenderer.Render(list, theme), - Table table => TableRenderer.Render(table, theme), - FencedCodeBlock fencedCode => CodeBlockRenderer.RenderFencedCodeBlock(fencedCode, theme, themeName), - CodeBlock indentedCode => CodeBlockRenderer.RenderCodeBlock(indentedCode, theme), + HeadingBlock heading + => [HeadingRenderer.Render(heading, theme)], + ParagraphBlock paragraph + => [ParagraphRenderer.Render(paragraph, theme)], + ListBlock list + => ListRenderer.Render(list, theme), + Table table + => TableRenderer.Render(table, theme) is IRenderable t ? [t] : [], + FencedCodeBlock fencedCode + => CodeBlockRenderer.RenderFencedCodeBlock(fencedCode, theme, themeName) is IRenderable fc ? [fc] : [], + CodeBlock indentedCode + => CodeBlockRenderer.RenderCodeBlock(indentedCode, theme) is IRenderable ic ? [ic] : [], // Keep existing renderers for remaining complex blocks - QuoteBlock quote => QuoteRenderer.Render(quote, theme), - HtmlBlock html => HtmlBlockRenderer.Render(html, theme, themeName), - ThematicBreakBlock => HorizontalRuleRenderer.Render(), + QuoteBlock quote + => [QuoteRenderer.Render(quote, theme)], + HtmlBlock html + => HtmlBlockRenderer.Render(html, theme, themeName) is IRenderable h ? [h] : [], + ThematicBreakBlock + => [HorizontalRuleRenderer.Render()], // Unsupported block types - _ => null + _ => [] }; } - /// - /// Checks if a paragraph block contains only a single image (no other text). - /// - private static bool IsStandaloneImage(ParagraphBlock paragraph) { - if (paragraph.Inline is null) { - return false; - } - - // Check if the paragraph contains only one LinkInline with IsImage = true - var inlines = paragraph.Inline.ToList(); - - // Single image case - if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { - return true; - } - - // Sometimes there might be whitespace inlines around the image - // Filter out empty/whitespace literals - var nonWhitespace = inlines - .Where(i => i is not LineBreakInline && !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) - .ToList(); - - return nonWhitespace.Count == 1 - && nonWhitespace[0] is LinkInline imageLink - && imageLink.IsImage; - } - /// /// Renders a standalone image (paragraph containing only an image). /// Demonstrates how SixelImage can be directly rendered or wrapped in containers. diff --git a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs b/src/Rendering/CodeBlockRenderer.cs similarity index 95% rename from src/Core/Markdown/Renderers/CodeBlockRenderer.cs rename to src/Rendering/CodeBlockRenderer.cs index 814b53c..780e4c9 100644 --- a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs +++ b/src/Rendering/CodeBlockRenderer.cs @@ -1,14 +1,15 @@ -using System.Buffers; +using System.Buffers; using System.Text; using Markdig.Helpers; using Markdig.Syntax; -using PwshSpectreConsole.TextMate.Extensions; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; +using PSTextMate.Core; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Code block renderer that builds Spectre.Console objects directly diff --git a/src/Core/Markdown/Renderers/HeadingRenderer.cs b/src/Rendering/HeadingRenderer.cs similarity index 54% rename from src/Core/Markdown/Renderers/HeadingRenderer.cs rename to src/Rendering/HeadingRenderer.cs index f3f8b54..b4dc67c 100644 --- a/src/Core/Markdown/Renderers/HeadingRenderer.cs +++ b/src/Rendering/HeadingRenderer.cs @@ -1,12 +1,13 @@ -using Markdig.Syntax; +using Markdig.Syntax; using Markdig.Syntax.Inlines; using Spectre.Console; using Spectre.Console.Rendering; using System.Text; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; +using PSTextMate.Core; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Heading renderer that builds Spectre.Console objects directly instead of markup strings. @@ -31,78 +32,15 @@ public static IRenderable Render(HeadingBlock heading, Theme theme) { // Build styling directly Style headingStyle = CreateHeadingStyle(hfg, hbg, hfs, theme, heading.Level); - // Return Text object directly - no markup parsing needed - return new Rows(new Paragraph(headingText, headingStyle)); + // Return Paragraph; Spectre.Console Rows will handle block separation + return new Paragraph(headingText, headingStyle); } /// /// Extracts plain text from heading inline elements without building markup. /// - private static string ExtractHeadingText(HeadingBlock heading) { - if (heading.Inline is null) - return ""; - - StringBuilder textBuilder = StringBuilderPool.Rent(); - try { - - foreach (Inline inline in heading.Inline) { - switch (inline) { - case LiteralInline literal: - textBuilder.Append(literal.Content.ToString()); - break; - - case EmphasisInline emphasis: - // For headings, we'll just extract the text without emphasis styling - // since the heading style takes precedence - ExtractInlineTextRecursive(emphasis, textBuilder); - break; - - case CodeInline code: - textBuilder.Append(code.Content); - break; - - case LinkInline link: - // Extract link text, not the URL - ExtractInlineTextRecursive(link, textBuilder); - break; - - default: - ExtractInlineTextRecursive(inline, textBuilder); - break; - } - } - - return textBuilder.ToString(); - } - finally { - StringBuilderPool.Return(textBuilder); - } - } - - /// - /// Recursively extracts text from inline elements. - /// - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { - switch (inline) { - case LiteralInline literal: - builder.Append(literal.Content.ToString()); - break; - - case ContainerInline container: - foreach (Inline child in container) { - ExtractInlineTextRecursive(child, builder); - } - break; - - case LeafInline leaf: - if (leaf is CodeInline code) { - builder.Append(code.Content); - } - break; - default: - break; - } - } + private static string ExtractHeadingText(HeadingBlock heading) + => InlineTextExtractor.ExtractAllText(heading.Inline); /// /// Creates appropriate styling for headings based on theme and level. diff --git a/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs b/src/Rendering/HorizontalRuleRenderer.cs similarity index 67% rename from src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs rename to src/Rendering/HorizontalRuleRenderer.cs index 0409130..253479e 100644 --- a/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs +++ b/src/Rendering/HorizontalRuleRenderer.cs @@ -1,7 +1,7 @@ using Spectre.Console; using Spectre.Console.Rendering; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Renders markdown horizontal rules (thematic breaks). @@ -11,5 +11,6 @@ internal static class HorizontalRuleRenderer { /// Renders a horizontal rule as a styled line. /// /// Rendered horizontal rule - public static IRenderable Render() => new Rule().RuleStyle(Style.Parse("grey")); + public static IRenderable Render() + => new Rule().RuleStyle(Style.Parse("grey")); } diff --git a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs b/src/Rendering/HtmlBlockRenderer.cs similarity index 94% rename from src/Core/Markdown/Renderers/HtmlBlockRenderer.cs rename to src/Rendering/HtmlBlockRenderer.cs index 9d80b03..5def870 100644 --- a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs +++ b/src/Rendering/HtmlBlockRenderer.cs @@ -3,8 +3,9 @@ using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; +using PSTextMate.Core; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Renders HTML blocks with syntax highlighting. diff --git a/src/Core/Markdown/Renderers/ImageBlockRenderer.cs b/src/Rendering/ImageBlockRenderer.cs similarity index 88% rename from src/Core/Markdown/Renderers/ImageBlockRenderer.cs rename to src/Rendering/ImageBlockRenderer.cs index 576adde..65125c3 100644 --- a/src/Core/Markdown/Renderers/ImageBlockRenderer.cs +++ b/src/Rendering/ImageBlockRenderer.cs @@ -1,7 +1,7 @@ -using Spectre.Console; +using Spectre.Console; using Spectre.Console.Rendering; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Handles rendering of images at the block level with support for captions and layouts. @@ -24,14 +24,13 @@ internal static class ImageBlockRenderer { // Get the base image renderable (either SixelImage or fallback) IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); - if (imageRenderable is null) { - return null; - } + if (imageRenderable is null) return null; // Apply the rendering mode to wrap/position the image return renderMode switch { // Render directly (no wrapper) - good for standalone images - ImageRenderMode.Direct => imageRenderable, + ImageRenderMode.Direct + => imageRenderable, // Wrap in Panel with title - good for captioned images ImageRenderMode.PanelWithCaption when !string.IsNullOrEmpty(altText) @@ -49,10 +48,14 @@ internal static class ImageBlockRenderer { // Wrap with padding ImageRenderMode.WithPadding => new Padder(imageRenderable, new Padding(1, 0)), - ImageRenderMode.SideCaption => throw new NotImplementedException(), - ImageRenderMode.VerticalCaption => throw new NotImplementedException(), - ImageRenderMode.Grid => throw new NotImplementedException(), - ImageRenderMode.TableCell => throw new NotImplementedException(), + ImageRenderMode.SideCaption + => RenderImageWithSideCaption(altText, imageUrl, altText), + ImageRenderMode.VerticalCaption + => RenderImageWithVerticalCaption(altText, imageUrl, altText), + ImageRenderMode.Grid + => RenderImageInGrid(altText, imageUrl, topCaption: altText), + ImageRenderMode.TableCell + => RenderImageInTable(altText, imageUrl, altText), _ => imageRenderable }; } diff --git a/src/Core/Markdown/Renderers/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs similarity index 97% rename from src/Core/Markdown/Renderers/ImageRenderer.cs rename to src/Rendering/ImageRenderer.cs index f8a732b..82312ed 100644 --- a/src/Core/Markdown/Renderers/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -1,11 +1,11 @@ using System.Reflection; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; #pragma warning disable CS0103 // The name 'SixelImage' does not exist in the current context -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Handles rendering of images in markdown using Sixel format when possible. diff --git a/src/Core/Markdown/Renderers/ListRenderer.cs b/src/Rendering/ListRenderer.cs similarity index 76% rename from src/Core/Markdown/Renderers/ListRenderer.cs rename to src/Rendering/ListRenderer.cs index 76288a1..1243aa2 100644 --- a/src/Core/Markdown/Renderers/ListRenderer.cs +++ b/src/Rendering/ListRenderer.cs @@ -1,13 +1,13 @@ -using System.Text; +using System.Text; using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// List renderer that builds Spectre.Console objects directly instead of markup strings. @@ -24,8 +24,8 @@ internal static class ListRenderer { /// /// The list block to render /// Theme for styling - /// Rendered list as Rows containing separate Paragraphs for each item - public static IRenderable Render(ListBlock list, Theme theme) { + /// Rendered list items as individual Paragraphs (one per line) + public static IEnumerable Render(ListBlock list, Theme theme) { var renderables = new List(); int number = 1; @@ -45,8 +45,8 @@ public static IRenderable Render(ListBlock list, Theme theme) { } } - // Rows will add a single line break after each child - return new Rows([.. renderables]); + // Return items individually - each list item is its own line + return renderables; } /// @@ -70,23 +70,6 @@ private static (bool isTaskList, bool isChecked) DetectTaskListItem(ListItemBloc private static string CreateListPrefixText(bool isOrdered, bool isTaskList, bool isChecked, ref int number) => isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; - /// - /// Creates the appropriate prefix for list items as styled Text objects. - /// - private static Text CreateListPrefix(bool isOrdered, bool isTaskList, bool isChecked, ref int number) { - if (isTaskList) { - string emoji = isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji; - return new Text(emoji, Style.Plain); - } - else if (isOrdered) { - string numberText = $"{number++}. "; - return new Text(numberText, Style.Plain); - } - else { - return new Text(UnorderedBullet, Style.Plain); - } - } - /// /// Appends list item content directly to the paragraph using styled Text objects. /// This eliminates the need for markup parsing and VT escaping. @@ -138,7 +121,7 @@ private static void AppendInlineContent(Paragraph paragraph, ContainerInline? in private static string ExtractInlineText(Inline inline) { StringBuilder builder = StringBuilderPool.Rent(); try { - ExtractInlineTextRecursive(inline, builder); + InlineTextExtractor.ExtractText(inline, builder); return builder.ToString(); } finally { @@ -146,31 +129,7 @@ private static string ExtractInlineText(Inline inline) { } } - /// - /// Recursively extracts text from inline elements. - /// - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { - switch (inline) { - case LiteralInline literal: - builder.Append(literal.Content.ToString()); - break; - - case ContainerInline container: - foreach (Inline child in container) { - ExtractInlineTextRecursive(child, builder); - } - break; - case LeafInline leaf: - // For leaf inlines like CodeInline, extract their content - if (leaf is CodeInline code) { - builder.Append(code.Content); - } - break; - default: - break; - } - } /// /// Renders nested lists as indented text content. diff --git a/src/Rendering/MarkdownRenderer.cs b/src/Rendering/MarkdownRenderer.cs new file mode 100644 index 0000000..a38b4ac --- /dev/null +++ b/src/Rendering/MarkdownRenderer.cs @@ -0,0 +1,113 @@ +using Markdig; +using Markdig.Helpers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// Markdown renderer that builds Spectre.Console objects directly instead of markup strings. +/// This eliminates VT escaping issues and avoids double-parsing overhead for better performance. +/// +internal static class MarkdownRenderer { + /// + /// Cached Markdig pipeline with trivia tracking enabled. + /// Pipelines are expensive to create, so we cache it as a static field for reuse. + /// Thread-safe: Markdig pipelines are immutable once built. + /// + private static readonly MarkdownPipeline _pipeline = CreateMarkdownPipeline(); + + /// + /// Renders markdown content using Spectre.Console object building. + /// This approach eliminates VT escaping issues and improves performance. + /// + /// Markdown text (can be multi-line) + /// Theme object for styling + /// Theme name for TextMateProcessor + /// Array of renderables for Spectre.Console rendering + public static IRenderable[] Render(string markdown, Theme theme, ThemeName themeName) { + MarkdownDocument? document = Markdown.Parse(markdown, _pipeline); + + var rows = new List(); + Block? previousBlock = null; + + for (int i = 0; i < document.Count; i++) { + Block? block = document[i]; + + // Skip redundant paragraph that Markdig sometimes produces on the same line as a table + if (block is ParagraphBlock && i + 1 < document.Count) { + Block nextBlock = document[i + 1]; + if (nextBlock is Markdig.Extensions.Tables.Table table && block.Line == table.Line) { + continue; + } + } + + // Calculate blank lines from source line numbers + // This is more reliable than trivia since extensions break trivia tracking + if (previousBlock is not null) { + int previousEndLine = GetBlockEndLine(previousBlock, markdown); + int gap = block.Line - previousEndLine - 1; + for (int j = 0; j < gap; j++) { + rows.Add(Text.Empty); + } + } + + // Render the block - returns IEnumerable + rows.AddRange(BlockRenderer.RenderBlock(block, theme, themeName)); + + previousBlock = block; + } + return [.. rows]; + } + + /// + /// Gets the ending line number of a block by counting newlines in the source span. + /// + private static int GetBlockEndLine(Block block, string markdown) { + // For container blocks, recursively find the last child's end line + if (block is ContainerBlock container && container.Count > 0) { + return GetBlockEndLine(container[^1], markdown); + } + // For fenced code blocks: opening fence + content lines + closing fence + if (block is FencedCodeBlock fenced && fenced.Lines.Count > 0) { + return block.Line + fenced.Lines.Count + 1; + } + // Count newlines within the block's span (excluding the final newline which separates blocks) + // The span typically includes the trailing newline, so we stop before Span.End + int endPosition = Math.Min(block.Span.End - 1, markdown.Length - 1); + int newlineCount = 0; + for (int i = block.Span.Start; i <= endPosition; i++) { + if (markdown[i] == '\n') { + newlineCount++; + } + } + return block.Line + newlineCount; + } + + /// + /// Returns true for blocks that render with visual borders and need padding. + /// + private static bool IsBorderedBlock(Block block) => + block is QuoteBlock or FencedCodeBlock or HtmlBlock or Markdig.Extensions.Tables.Table; + + /// + /// Creates the Markdig pipeline with all necessary extensions and trivia tracking enabled. + /// Pipeline follows Markdig's roundtrip parser design pattern - see: + /// https://github.com/xoofx/markdig/blob/master/src/Markdig/Roundtrip.md + /// + /// Configured MarkdownPipeline with trivia tracking enabled + private static MarkdownPipeline CreateMarkdownPipeline() { + return new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseTaskLists() + .UsePipeTables() + .UseAutoLinks() + .EnableTrackTrivia() + .Build(); + } +} diff --git a/src/Core/Markdown/Renderers/ParagraphRenderer.cs b/src/Rendering/ParagraphRenderer.cs similarity index 90% rename from src/Core/Markdown/Renderers/ParagraphRenderer.cs rename to src/Rendering/ParagraphRenderer.cs index 559cec8..cf9819d 100644 --- a/src/Core/Markdown/Renderers/ParagraphRenderer.cs +++ b/src/Rendering/ParagraphRenderer.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.RegularExpressions; using Markdig.Extensions; using Markdig.Extensions.AutoLinks; @@ -8,9 +8,10 @@ using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; +using PSTextMate.Core; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Paragraph renderer that builds Spectre.Console objects directly instead of markup strings. @@ -28,13 +29,13 @@ internal static partial class ParagraphRenderer { /// Theme for styling /// Rendered paragraph as a Paragraph object with proper inline styling public static IRenderable Render(ParagraphBlock paragraph, Theme theme) { - var spectreConsole = new Paragraph(); + var spectreParagraph = new Paragraph(); if (paragraph.Inline is not null) { - ProcessInlineElements(spectreConsole, paragraph.Inline, theme); + ProcessInlineElements(spectreParagraph, paragraph.Inline, theme); } - return spectreConsole; + return spectreParagraph; } /// @@ -45,8 +46,25 @@ public static IRenderable Render(ParagraphBlock paragraph, Theme theme) { /// Theme for styling /// If true, skips LineBreakInline (used for list items where Rows handles spacing) internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline inlines, Theme theme, bool skipLineBreaks = false) { - foreach (Inline inline in inlines) { - // Console.WriteLine($"Inline: {inline}"); + // Convert to list to allow index-based access for checking trailing line breaks + List inlineList = [.. inlines]; + + for (int i = 0; i < inlineList.Count; i++) { + Inline inline = inlineList[i]; + + // Check if this is a trailing line break (last element or followed only by other line breaks) + bool isTrailingLineBreak = false; + if (inline is LineBreakInline && i < inlineList.Count) { + isTrailingLineBreak = true; + // Check if there are any non-LineBreakInline elements after this + for (int j = i + 1; j < inlineList.Count; j++) { + if (inlineList[j] is not LineBreakInline) { + isTrailingLineBreak = false; + break; + } + } + } + switch (inline) { case LiteralInline literal: string literalText = literal.Content.ToString(); @@ -98,8 +116,9 @@ internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline break; case LineBreakInline: - // Skip line breaks in lists (Rows handles spacing), but keep them in regular paragraphs - if (!skipLineBreaks) { + // Skip trailing line breaks to avoid double-spacing with Rows container + // Also skip line breaks in lists (Rows handles spacing) + if (!skipLineBreaks && !isTrailingLineBreak) { paragraph.Append("\n", Style.Plain); } break; @@ -343,7 +362,7 @@ private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline au private static string ExtractInlineText(Inline inline) { StringBuilder builder = StringBuilderPool.Rent(); try { - ExtractInlineTextRecursive(inline, builder); + InlineTextExtractor.ExtractText(inline, builder); return builder.ToString(); } finally { @@ -392,30 +411,7 @@ private static bool TryParseUsernameLinks(string text, out TextSegment[] segment return true; } - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { - switch (inline) { - case LiteralInline literal: - builder.Append(literal.Content.ToString()); - break; - case ContainerInline container: - foreach (Inline child in container) { - ExtractInlineTextRecursive(child, builder); - } - break; - - case LeafInline leaf: - if (leaf is CodeInline code) { - builder.Append(code.Content); - } - else if (leaf is LineBreakInline) { - builder.Append('\n'); - } - break; - default: - break; - } - } [GeneratedRegex(@"@[a-zA-Z0-9_-]+")] private static partial Regex RegNumLet(); diff --git a/src/Core/Markdown/Renderers/QuoteRenderer.cs b/src/Rendering/QuoteRenderer.cs similarity index 86% rename from src/Core/Markdown/Renderers/QuoteRenderer.cs rename to src/Rendering/QuoteRenderer.cs index bdb6ad2..948c853 100644 --- a/src/Core/Markdown/Renderers/QuoteRenderer.cs +++ b/src/Rendering/QuoteRenderer.cs @@ -3,9 +3,9 @@ using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Renders markdown quote blocks. @@ -40,7 +40,9 @@ private static string ExtractQuoteText(QuoteBlock quote, Theme theme) { StringBuilder quoteBuilder = StringBuilderPool.Rent(); try { - InlineProcessor.ExtractInlineText(para.Inline, theme, quoteBuilder); + if (para.Inline is not null) { + InlineTextExtractor.ExtractText(para.Inline, quoteBuilder); + } quoteText += quoteBuilder.ToString(); } finally { diff --git a/src/Core/Markdown/Renderers/TableRenderer.cs b/src/Rendering/TableRenderer.cs similarity index 85% rename from src/Core/Markdown/Renderers/TableRenderer.cs rename to src/Rendering/TableRenderer.cs index b330c95..9d5c170 100644 --- a/src/Core/Markdown/Renderers/TableRenderer.cs +++ b/src/Rendering/TableRenderer.cs @@ -1,14 +1,15 @@ -using System.Text; +using System.Text; using Markdig.Extensions.Tables; using Markdig.Helpers; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using PwshSpectreConsole.TextMate.Helpers; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; +using PSTextMate.Core; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Table renderer that builds Spectre.Console objects directly instead of markup strings. @@ -157,7 +158,7 @@ private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBu break; case EmphasisInline emphasis: - ExtractInlineTextRecursive(emphasis, builder); + InlineTextExtractor.ExtractText(emphasis, builder); break; case CodeInline code: @@ -165,42 +166,17 @@ private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBu break; case LinkInline link: - ExtractInlineTextRecursive(link, builder); + InlineTextExtractor.ExtractText(link, builder); break; default: - ExtractInlineTextRecursive(inline, builder); + InlineTextExtractor.ExtractText(inline, builder); break; } } } - /// - /// Recursively extracts text from inline elements. - /// - private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) { - switch (inline) { - case LiteralInline literal: - if (literal.Content.Text is not null && literal.Content.Length > 0) { - builder.Append(literal.Content.Text.AsSpan(literal.Content.Start, literal.Content.Length)); - } - break; - - case ContainerInline container: - foreach (Inline child in container) { - ExtractInlineTextRecursive(child, builder); - } - break; - case LeafInline leaf: - if (leaf is CodeInline code) { - builder.Append(code.Content); - } - break; - default: - break; - } - } /// /// Gets the border style for tables based on theme. diff --git a/src/Helpers/Completers.cs b/src/Utilities/Completers.cs similarity index 96% rename from src/Helpers/Completers.cs rename to src/Utilities/Completers.cs index f5ebe2c..d70283e 100644 --- a/src/Helpers/Completers.cs +++ b/src/Utilities/Completers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -6,7 +6,7 @@ using System.Management.Automation.Language; using System.Text.RegularExpressions; -namespace PwshSpectreConsole.TextMate; +namespace PSTextMate; /// /// Argument completer for TextMate language IDs and file extensions in PowerShell commands. diff --git a/src/Helpers/Helpers.cs b/src/Utilities/Helpers.cs similarity index 92% rename from src/Helpers/Helpers.cs rename to src/Utilities/Helpers.cs index 5f269e3..07819a5 100644 --- a/src/Helpers/Helpers.cs +++ b/src/Utilities/Helpers.cs @@ -1,6 +1,6 @@ -using TextMateSharp.Grammars; +using TextMateSharp.Grammars; -namespace PwshSpectreConsole.TextMate; +namespace PSTextMate; /// /// Provides utility methods for accessing available TextMate languages and file extensions. diff --git a/src/Core/TextMateStyling/ITextMateStyler.cs b/src/Utilities/ITextMateStyler.cs similarity index 88% rename from src/Core/TextMateStyling/ITextMateStyler.cs rename to src/Utilities/ITextMateStyler.cs index 19b5b29..47c9cca 100644 --- a/src/Core/TextMateStyling/ITextMateStyler.cs +++ b/src/Utilities/ITextMateStyler.cs @@ -1,7 +1,7 @@ -using Spectre.Console; +using Spectre.Console; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core.TextMateStyling; +namespace PSTextMate.Core; /// /// Abstraction for applying TextMate token styles to text. diff --git a/src/Helpers/ImageFile.cs b/src/Utilities/ImageFile.cs similarity index 95% rename from src/Helpers/ImageFile.cs rename to src/Utilities/ImageFile.cs index c4bd488..0de7ccf 100644 --- a/src/Helpers/ImageFile.cs +++ b/src/Utilities/ImageFile.cs @@ -1,4 +1,4 @@ -// class to normalize image file path/url/base64, basically any image source that is allowed in markdown. +// class to normalize image file path/url/base64, basically any image source that is allowed in markdown. // if it is something Spectre.Console.SixelImage(string filename, bool animations) cannot handle we need to fix that, like downloading to a temporary file or converting the base64 to a file.. using System; @@ -8,7 +8,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace PwshSpectreConsole.TextMate.Helpers; +namespace PSTextMate.Utilities; /// /// Normalizes various image sources (file paths, URLs, base64) into file paths that can be used by Spectre.Console.SixelImage. diff --git a/src/Utilities/InlineTextExtractor.cs b/src/Utilities/InlineTextExtractor.cs new file mode 100644 index 0000000..cbc33e2 --- /dev/null +++ b/src/Utilities/InlineTextExtractor.cs @@ -0,0 +1,57 @@ +using Markdig.Syntax.Inlines; +using System.Text; + +namespace PSTextMate.Utilities; + +/// +/// Utility for extracting plain text from Markdig inline elements. +/// Consolidates text extraction logic used across multiple renderers. +/// +internal static class InlineTextExtractor { + /// + /// Recursively extracts plain text from inline elements. + /// + /// The inline element to extract text from + /// StringBuilder to append extracted text to + public static void ExtractText(Inline inline, StringBuilder builder) { + switch (inline) { + case LiteralInline literal: + builder.Append(literal.Content.ToString()); + break; + + case ContainerInline container: + foreach (Inline child in container) { + ExtractText(child, builder); + } + break; + + case LeafInline leaf when leaf is CodeInline code: + builder.Append(code.Content); + break; + default: + break; + } + } + + /// + /// Extracts all text from an inline container into a single string. + /// + /// The container to extract from + /// Extracted plain text + public static string ExtractAllText(ContainerInline? inlineContainer) { + if (inlineContainer is null) { + return string.Empty; + } + + StringBuilder builder = StringBuilderPool.Rent(); + try { + foreach (Inline inline in inlineContainer) { + ExtractText(inline, builder); + } + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } + } +} diff --git a/src/Utilities/MarkdownPatterns.cs b/src/Utilities/MarkdownPatterns.cs new file mode 100644 index 0000000..8dc175e --- /dev/null +++ b/src/Utilities/MarkdownPatterns.cs @@ -0,0 +1,41 @@ +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace PSTextMate.Utilities; + +/// +/// Utility for detecting common markdown patterns like standalone images. +/// Consolidates pattern detection logic used across multiple renderers. +/// +internal static class MarkdownPatterns { + /// + /// Checks if a paragraph block contains only a single image (no other text). + /// Used to apply special rendering or spacing for standalone images. + /// + /// The paragraph block to check + /// True if paragraph contains only an image, false otherwise + public static bool IsStandaloneImage(ParagraphBlock paragraph) { + if (paragraph.Inline is null) { + return false; + } + + // Check if the paragraph contains only one LinkInline with IsImage = true + var inlines = paragraph.Inline.ToList(); + + // Single image case + if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { + return true; + } + + // Sometimes there might be whitespace inlines around the image + // Filter out empty/whitespace literals + var nonWhitespace = inlines + .Where(i => i is not LineBreakInline && + !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) + .ToList(); + + return nonWhitespace.Count == 1 + && nonWhitespace[0] is LinkInline imageLink + && imageLink.IsImage; + } +} diff --git a/src/Extensions/SpanOptimizedStringExtensions.cs b/src/Utilities/SpanOptimizedStringExtensions.cs similarity index 96% rename from src/Extensions/SpanOptimizedStringExtensions.cs rename to src/Utilities/SpanOptimizedStringExtensions.cs index 429d403..44954c0 100644 --- a/src/Extensions/SpanOptimizedStringExtensions.cs +++ b/src/Utilities/SpanOptimizedStringExtensions.cs @@ -1,6 +1,6 @@ using System.Text; -namespace PwshSpectreConsole.TextMate.Extensions; +namespace PSTextMate.Utilities; /// /// Enhanced string manipulation methods optimized with Span operations. diff --git a/src/Core/TextMateStyling/SpectreTextMateStyler.cs b/src/Utilities/SpectreTextMateStyler.cs similarity index 94% rename from src/Core/TextMateStyling/SpectreTextMateStyler.cs rename to src/Utilities/SpectreTextMateStyler.cs index 7b9c3e0..f091585 100644 --- a/src/Core/TextMateStyling/SpectreTextMateStyler.cs +++ b/src/Utilities/SpectreTextMateStyler.cs @@ -1,9 +1,9 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Runtime.CompilerServices; using Spectre.Console; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core.TextMateStyling; +namespace PSTextMate.Core; /// /// Spectre.Console implementation of ITextMateStyler. diff --git a/src/Extensions/StringBuilderExtensions.cs b/src/Utilities/StringBuilderExtensions.cs similarity index 96% rename from src/Extensions/StringBuilderExtensions.cs rename to src/Utilities/StringBuilderExtensions.cs index a000733..0027f27 100644 --- a/src/Extensions/StringBuilderExtensions.cs +++ b/src/Utilities/StringBuilderExtensions.cs @@ -2,7 +2,7 @@ using System.Text; using Spectre.Console; -namespace PwshSpectreConsole.TextMate.Extensions; +namespace PSTextMate.Utilities; /// /// Provides optimized StringBuilder extension methods for text rendering operations. diff --git a/src/Helpers/StringBuilderPool.cs b/src/Utilities/StringBuilderPool.cs similarity index 78% rename from src/Helpers/StringBuilderPool.cs rename to src/Utilities/StringBuilderPool.cs index 3eb8505..2317a2e 100644 --- a/src/Helpers/StringBuilderPool.cs +++ b/src/Utilities/StringBuilderPool.cs @@ -1,7 +1,7 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Text; -namespace PwshSpectreConsole.TextMate.Helpers; +namespace PSTextMate.Utilities; internal static class StringBuilderPool { private static readonly ConcurrentBag _bag = []; diff --git a/src/Extensions/StringExtensions.cs b/src/Utilities/StringExtensions.cs similarity index 95% rename from src/Extensions/StringExtensions.cs rename to src/Utilities/StringExtensions.cs index e21cb85..791a4ac 100644 --- a/src/Extensions/StringExtensions.cs +++ b/src/Utilities/StringExtensions.cs @@ -1,4 +1,4 @@ -namespace PwshSpectreConsole.TextMate.Extensions; +namespace PSTextMate.Utilities; /// /// Provides optimized string manipulation methods using modern .NET performance patterns. diff --git a/src/Helpers/TextMateResolver.cs b/src/Utilities/TextMateResolver.cs similarity index 91% rename from src/Helpers/TextMateResolver.cs rename to src/Utilities/TextMateResolver.cs index 47d29cb..5bdb41c 100644 --- a/src/Helpers/TextMateResolver.cs +++ b/src/Utilities/TextMateResolver.cs @@ -1,6 +1,6 @@ -using System; +using System; -namespace PwshSpectreConsole.TextMate; +namespace PSTextMate; /// /// Resolves a user-provided token into either a TextMate language id or a file extension. diff --git a/src/Extensions/ThemeExtensions.cs b/src/Utilities/ThemeExtensions.cs similarity index 93% rename from src/Extensions/ThemeExtensions.cs rename to src/Utilities/ThemeExtensions.cs index ad77831..f908a47 100644 --- a/src/Extensions/ThemeExtensions.cs +++ b/src/Utilities/ThemeExtensions.cs @@ -1,8 +1,8 @@ -using PwshSpectreConsole.TextMate.Core; +using PSTextMate.Core; using Spectre.Console; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Extensions; +namespace PSTextMate.Utilities; /// /// Extension methods for converting TextMate themes and colors to Spectre.Console styling. diff --git a/src/Core/TextMateStyling/TokenStyleProcessor.cs b/src/Utilities/TokenStyleProcessor.cs similarity index 93% rename from src/Core/TextMateStyling/TokenStyleProcessor.cs rename to src/Utilities/TokenStyleProcessor.cs index 5bf5b85..917c738 100644 --- a/src/Core/TextMateStyling/TokenStyleProcessor.cs +++ b/src/Utilities/TokenStyleProcessor.cs @@ -1,10 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core.TextMateStyling; +namespace PSTextMate.Core; /// /// Processes tokens and applies TextMate styling to produce Spectre renderables. diff --git a/src/Helpers/VTConversion.cs b/src/Utilities/VTConversion.cs similarity index 97% rename from src/Helpers/VTConversion.cs rename to src/Utilities/VTConversion.cs index 67afc98..6acb8a2 100644 --- a/src/Helpers/VTConversion.cs +++ b/src/Utilities/VTConversion.cs @@ -1,8 +1,8 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Text; using Spectre.Console; -namespace PwshSpectreConsole.TextMate.Core.Helpers; +namespace PSTextMate.Core.Helpers; /// /// Efficient parser for VT (Virtual Terminal) escape sequences that converts them to Spectre.Console objects. diff --git a/tests/line-test-1.md b/tests/line-test-1.md new file mode 100644 index 0000000..011d919 --- /dev/null +++ b/tests/line-test-1.md @@ -0,0 +1,18 @@ +# Simple Spacing Test + +Intro paragraph. +Second line of intro. + +## Section A + +Paragraph A line 1. +Paragraph A line 2. + +- Item 1 +- Item 2 + +## Section B + +Paragraph B after list. + +Trailing line after blank. diff --git a/tests/line-test-2.md b/tests/line-test-2.md new file mode 100644 index 0000000..6042fb5 --- /dev/null +++ b/tests/line-test-2.md @@ -0,0 +1,18 @@ +# Mixed Blocks Spacing + +Paragraph before code block. + +``` +code line 1 +code line 2 +``` + +> Quote line 1 +> Quote line 2 + +| Col1 | Col2 | +| ---- | ---- | +| A | 1 | +| B | 2 | + +End paragraph after table. diff --git a/tests/test-markdown.md b/tests/test-markdown.md index 7c34fc8..367f3e5 100644 --- a/tests/test-markdown.md +++ b/tests/test-markdown.md @@ -80,7 +80,7 @@ public static bool IsSupportedFile(string file) { ## Images -![xkcd git](../assets/git_commit.png) +[xkcd git](../assets/git_commit.png) ## Horizontal Rule From 3db743283090a98e42581d59b32f25d053d1281e Mon Sep 17 00:00:00 2001 From: trackd Date: Sun, 25 Jan 2026 23:47:42 +0100 Subject: [PATCH 12/25] =?UTF-8?q?feat(rendering):=20=E2=9C=A8=20Enhance=20?= =?UTF-8?q?rendering=20with=20Spectre.Console=20Text=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced `ProcessTokensToParagraph` method for efficient token processing. * Updated various renderers (e.g., `BlockRenderer`, `HtmlBlockRenderer`, `ImageBlockRenderer`) to utilize `Text` objects instead of `Markup`, improving performance and reducing markup parsing overhead. * Enhanced `ParagraphRenderer` to build `Text` segments directly, avoiding extra spacing in terminal output. * Added integration tests to ensure inline code, links, and image text are rendered correctly. * Refactored inline content processing in `ListRenderer` and `QuoteRenderer` for consistency with new rendering approach. --- .gitignore | 2 + Copilot-Processing.md | 344 ---------------- rep.ps1 | 6 +- src/Commands/ShowTextMateCmdlet.cs | 18 +- src/Core/StandardRenderer.cs | 17 +- src/Core/TextMateProcessor.cs | 26 +- src/Core/TokenProcessor.cs | 29 ++ src/Rendering/BlockRenderer.cs | 11 +- src/Rendering/HtmlBlockRenderer.cs | 6 +- src/Rendering/ImageBlockRenderer.cs | 20 +- src/Rendering/ImageRenderer.cs | 28 +- src/Rendering/ListRenderer.cs | 36 +- src/Rendering/ParagraphRenderer.cs | 375 ++++++------------ src/Rendering/QuoteRenderer.cs | 3 +- .../Integration/RenderingIntegrationTests.cs | 45 +++ tests/test-markdown.md | 2 +- tools/analyze-block-lines.cs | 63 +++ tools/diagnostic-trivia.cs | 98 +++++ tools/test-line-calc.cs | 51 +++ 19 files changed, 531 insertions(+), 649 deletions(-) delete mode 100644 Copilot-Processing.md create mode 100644 tests/Integration/RenderingIntegrationTests.cs create mode 100644 tools/analyze-block-lines.cs create mode 100644 tools/diagnostic-trivia.cs create mode 100644 tools/test-line-calc.cs diff --git a/.gitignore b/.gitignore index ffaea79..60b7758 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ debug.md .github/instructions/* .github/prompts/* ref/** +Copilot-Processing.md +PSTextMate-Rendering-Analysis.md diff --git a/Copilot-Processing.md b/Copilot-Processing.md deleted file mode 100644 index d429e51..0000000 --- a/Copilot-Processing.md +++ /dev/null @@ -1,344 +0,0 @@ -# Copilot Processing Log - -## User Request - -Fix compilation errors caused by namespace conflicts between `Core.Rows` and `Spectre.Console.Rows`. Three errors: - -1. `HighlightedText.cs(18,34)`: 'Rows' does not contain a definition for 'Count' -2. `ShowTextMateCmdlet.cs(147,27)`: Cannot convert Spectre.Console.Rows to Core.Rows -3. `ShowTextMateCmdlet.cs(184,31)`: Cannot convert Spectre.Console.Rows to Core.Rows - -## Analysis Complete - -### Current Architecture - -- **Core.Rows** - Internal record type with: - - `IRenderable[] Renderables` property - - `Count` property (implements IReadOnlyList) - - Simple wrapper around array - -- **Spectre.Console.Rows** - External library type: - - Constructor: `new Spectre.Console.Rows(IRenderable[])` - - Appears to be collection type (has Count) - - Used for final rendering output - -### Root Cause - -Claude attempted to make `HighlightedText` use `Spectre.Console.Rows` directly, but: - -1. Changed property type without verifying Spectre.Console.Rows has Count -2. Cmdlet still tries to create `Spectre.Console.Rows` from `Core.Rows` return values -3. Missing conversion from `Core.Rows` to `Spectre.Console.Rows` - -### Solution Strategy - -Keep internal/external separation clean: - -- Internal processing: Uses `Core.Rows` (lightweight, efficient) -- Public API boundary: Convert to `Spectre.Console.Rows` via `Converter.ProcessLines()` -- `HighlightedText`: Store `Core.Rows` internally, expose count directly -- Conversion happens at API boundary, not in domain types - -## Action Plan - -### Phase 1: Fix HighlightedText ✅ - -- [x] Change `HighlightedText.Rows` back to `Core.Rows` type -- [x] Fix `LineCount` property to use `Core.Rows.Count` -- [x] Update `ToPanel()` and `WithPadding()` to convert to Spectre.Console.Rows - -### Phase 2: Fix ShowTextMateCmdlet ✅ - -- [x] Update `ProcessStringInput()` to return Core.Rows directly (no conversion) -- [x] Update `ProcessPathInput()` to return Core.Rows directly (no conversion) -- [x] Ensure HighlightedText wraps Core.Rows properly - -### Phase 3: Verify TextMateProcessor Compatibility ✅ - -- [x] Ensure ProcessLinesInBatches returns HighlightedText with Core.Rows -- [x] Check conversion points are correct - -### Phase 4: Build and Verify ✅ - -- [x] Build solution -- [x] Fix any remaining compilation errors -- [x] Verify architecture is clean - -## Summary - -### Changes Implemented - -1. **[HighlightedText.cs](src/Core/HighlightedText.cs)** - - Changed `Rows` property from `Spectre.Console.Rows` to `Core.Rows` - - `LineCount` now correctly uses `Core.Rows.Count` - - `ToPanel()` converts `Core.Rows.Renderables` to `Spectre.Console.Rows` at rendering time - - `WithPadding()` methods convert `Core.Rows.Renderables` to `Spectre.Console.Rows` at rendering time - -2. **[ShowTextMateCmdlet.cs](src/Cmdlets/ShowTextMateCmdlet.cs)** - - `ProcessStringInput()` now returns `HighlightedText` with `Core.Rows` directly - - `ProcessPathInput()` non-streaming case returns `HighlightedText` with `Core.Rows` directly - - Removed incorrect `Converter.ProcessLines()` calls and conversions - - Now uses `TextMateProcessor.ProcessLines()` directly which returns `Core.Rows` - -3. **[TextMateProcessor.cs](src/Core/TextMateProcessor.cs)** - - `ProcessLinesInBatches()` now creates `HighlightedText` with `Core.Rows` instead of converting to `Spectre.Console.Rows` - - Both batch and final batch processing now wrap `Core.Rows` directly - -### Architecture Verification - -✅ **Internal Processing Layer** - -- All processing methods return `Core.Rows` (lightweight wrapper) -- No unnecessary conversions during processing -- Efficient memory usage - -✅ **Public API Boundary** - -- `Converter.ProcessLines()` converts `Core.Rows` → `Spectre.Console.Rows` for consumers who need it -- `HighlightedText` stores `Core.Rows` internally -- Conversion to `Spectre.Console.Rows` happens only when rendering (ToPanel, WithPadding) - -✅ **Type Safety** - -- No namespace ambiguity -- Clear separation between internal and external types -- Proper conversions at boundaries - -### Build Results - -``` -Restore complete (0,5s) - PSTextMate net8.0 succeeded (0,8s) → src\bin\Debug\net8.0\PSTextMate.dll - -Build succeeded in 1,5s -``` - -**All compilation errors resolved. No errors or warnings.** - -## Status - -✅ **Complete** - All phases executed successfully. Solution builds without errors. - ---- - -## Architectural Analysis: Do We Need Core.Rows? - -### Current Reality - -**Core.Rows is just a thin wrapper:** -```csharp -public sealed record Rows(IRenderable[] Renderables) : IReadOnlyList { - public static Rows Empty { get; } = new Rows([]); - public int Count => Renderables.Length; - public IRenderable this[int index] => Renderables[index]; -} -``` - -**Spectre.Console.Rows does the same thing:** -- Takes `IRenderable[]` in constructor -- Implements collection interfaces -- Used for rendering multiple lines - -### The Only Real Difference - -**Dependency isolation** - `Core.Rows` means our Core layer doesn't reference Spectre.Console types directly. But we're already: -- Using `Spectre.Console.Rendering.IRenderable` everywhere -- Creating `Markup` and `Text` objects in renderers -- So this "isolation" is already broken - -### Usage Pattern Analysis - -**Current flow with conversion overhead:** -``` -StandardRenderer → new Core.Rows([.. rows]) - → stored in HighlightedText - → converted to new Spectre.Console.Rows(rows.Renderables) - → used in Panel/Padder -``` - -**Simpler flow if we used Spectre.Console.Rows directly:** -``` -StandardRenderer → new Spectre.Console.Rows([.. rows]) - → stored in HighlightedText - → used directly in Panel/Padder (no conversion!) -``` - -### Output Container Options Analysis - -| Container | Pros | Cons | Best For | -|-----------|------|------|----------| -| **Rows** | ✅ Lightweight
✅ Perfect for streaming
✅ Composable
✅ No rendering overhead | ❌ Plain (no decoration) | ✅ Streaming output
✅ Internal processing
✅ Building blocks | -| **Panel** | ✅ Professional look
✅ Built-in borders
✅ Titles/headers | ❌ Can't stream (single unit)
❌ Rendering overhead
❌ Not composable | Single-shot output
User can wrap Rows themselves | -| **Paragraphs** | ✅ Text wrapping | ❌ Not for code
❌ Loses formatting | ❌ Not suitable | -| **Layout** | ✅ Complex arrangements | ❌ Overkill
❌ Can't stream | ❌ Too complex | - -### Recommendation: **Eliminate Core.Rows** - -**Why:** -1. **Zero benefit** - It's just an alias for what Spectre.Console.Rows already does -2. **Performance cost** - Extra allocations converting between types -3. **Code complexity** - Namespace confusion, extra conversions -4. **Already coupled** - Core layer already uses Spectre.Console types - -**Keep Rows (not Panel) because:** -- ✅ **Streaming**: Can yield multiple Rows objects one at a time -- ✅ **Efficiency**: Minimal overhead, no border rendering -- ✅ **Composability**: Users can wrap in Panel/Layout/Grid as needed -- ✅ **Flexibility**: Let PowerShell users decide presentation - -**Migration:** -```csharp -// Before: src/Core/StandardRenderer.cs -public static Core.Rows Render(...) { - return new Rows([.. rows]); // Core.Rows -} - -// After: src/Core/StandardRenderer.cs -public static Spectre.Console.Rows Render(...) { - return new Spectre.Console.Rows([.. rows]); // Direct Spectre type -} -``` - -### Files to Update - -1. Delete `src/Core/Rows.cs` entirely -2. Update all return types: `Core.Rows` → `Spectre.Console.Rows` -3. Update `HighlightedText.Rows` property type -4. Remove conversion code in `ToPanel()` and `WithPadding()` -5. Update `Converter.cs` (might not need it anymore) -6. Simplify `TextMateProcessor.cs` batch processing - -**Benefit:** Simpler, faster, more idiomatic Spectre.Console usage. - -Would you like me to implement this refactoring? - ---- - -## Refactoring: Eliminate Core.Rows - -### Phase 1: Update Renderers ✅ -- [x] StandardRenderer.cs - Return Spectre.Console.Rows -- [x] MarkdownRenderer.cs - Return Spectre.Console.Rows -- [x] HtmlBlockRenderer.cs - Remove conversion code -- [x] CodeBlockRenderer.cs - Remove conversion code - -### Phase 2: Update Core Types ✅ -- [x] HighlightedText.cs - Use Spectre.Console.Rows, remove conversions -- [x] TextMateProcessor.cs - Return Spectre.Console.Rows - -### Phase 3: Update Public API ✅ -- [x] ShowTextMateCmdlet.cs - Use Spectre.Console.Rows -- [x] Converter.cs - Simplify (no conversion needed) - -### Phase 4: Cleanup ✅ -- [x] Delete Core/Rows.cs -- [x] Build and verify - -**DISCOVERY:** Spectre.Console.Rows lacks Count/Renderables properties! - -### Phase 5: Pivot to IRenderable[] ✅ -- [x] Change all return types to IRenderable[] -- [x] Update HighlightedText to store IRenderable[] -- [x] Create Spectre.Console.Rows only when rendering -- [x] Build and verify - -## Final Architecture - -**Core.Rows has been eliminated** - it was redundant because: -- Spectre.Console.Rows lacks Count/Renderables properties -- Core.Rows provided those, but we can use `IRenderable[]` directly - -**New clean architecture:** -``` -Internal processing → returns IRenderable[] -HighlightedText → stores IRenderable[] -Rendering methods → create Spectre.Console.Rows([.. array]) -Converter → wraps IRenderable[] in Spectre.Console.Rows for consumers -``` - -**Benefits:** -- ✅ No custom wrapper types -- ✅ Direct array access (Count = .Length) -- ✅ Spectre.Console.Rows created only when needed for rendering -- ✅ Simpler, more maintainable code -- ✅ Better performance (fewer allocations) - -Build: **SUCCESS** ✅ - -## Final Cleanup - -✅ Deleted [Core/Rows.cs](src/Core/Rows.cs) -✅ Updated all test files: -- [StandardRendererTests.cs](tests/Core/StandardRendererTests.cs) -- [TextMateProcessorTests.cs](tests/Core/TextMateProcessorTests.cs) -- [MarkdownRendererTests.cs](tests/Core/Markdown/MarkdownRendererTests.cs) -- [TaskListIntegrationTests.cs](tests/Integration/TaskListIntegrationTests.cs) - -**All compilation issues resolved.** - -### Test Changes -- Replaced `result.Renderables.Should()...` with `result.Should()...` -- Fixed batch indexing in tests to use `batchList[index]` instead of `batches[index]` -- Fixed `.Count` vs `.Count()` issues for lists -- Satisfied CA1806 code analysis warnings - -### Files Modified in This Session -1. src/Core/StandardRenderer.cs - Returns IRenderable[] -2. src/Core/MarkdownRenderer.cs (facade) - Returns IRenderable[] -3. src/Core/Markdown/MarkdownRenderer.cs - Returns IRenderable[] -4. src/Core/TextMateProcessor.cs - Returns IRenderable[] -5. src/Core/HighlightedText.cs - Stores IRenderable[], creates Spectre.Console.Rows on demand -6. src/Cmdlets/ShowTextMateCmdlet.cs - Uses IRenderable[] -7. src/Compatibility/Converter.cs - Wraps IRenderable[] in Spectre.Console.Rows -8. src/Core/Markdown/Renderers/HtmlBlockRenderer.cs - Returns IRenderable[] -9. src/Core/Markdown/Renderers/CodeBlockRenderer.cs - Returns IRenderable[] -10. Tests - Updated to use IRenderable[] directly - -**Total: Clean refactoring complete!** ✨ - ---- - -## Summary of Complete Refactoring - -### Problem -The codebase had unnecessary complexity with both: -- `Core.Rows` (internal wrapper) -- `Spectre.Console.Rows` (external library type, missing Count/Renderables properties) - -This created duplicate types and conversion overhead. - -### Solution -**Eliminate all custom Rows types and use `IRenderable[]` directly** - -### Architecture Before → After - -**Before:** -``` -Renderers → Core.Rows → HighlightedText.Rows (Core.Rows) - → convert to Spectre.Console.Rows → Panel/Padder -``` - -**After:** -``` -Renderers → IRenderable[] → HighlightedText.Renderables (IRenderable[]) - → create Spectre.Console.Rows only when rendering -``` - -### Benefits -✅ **Eliminated complexity** - Removed redundant Core.Rows type -✅ **Better performance** - No unnecessary conversions -✅ **Simpler API** - Direct array access instead of wrapper properties -✅ **More idiomatic** - Uses standard C# arrays instead of custom types -✅ **Easier testing** - Tests work directly with arrays - -### Test Results -- ✅ 19 compilation errors fixed -- ✅ All tests compile -- ✅ All tests pass -- ✅ No warnings (except 1 xUnit1026 unused parameter, pre-existing) - -### Files Deleted -- [Core/Rows.cs](src/Core/Rows.cs) - No longer needed - -### Key Learnings -**Always check library API before wrapping**: Spectre.Console.Rows lacked crucial properties (Count, Renderables), making our internal wrapper actually MORE useful. By recognizing this, we chose the simplest solution: use raw arrays instead of any Rows type. diff --git a/rep.ps1 b/rep.ps1 index d2b6c1d..509588c 100644 --- a/rep.ps1 +++ b/rep.ps1 @@ -1,8 +1,10 @@ Push-Location $PSScriptRoot -& .\build.ps1 +$f = & .\build.ps1 +Import-Module PwshSpectreConsole Import-Module ./output/PSTextMate.psd1 # $c = Get-Content ./tests/test-markdown.md -Raw # $c | Show-TextMate -Verbose # Get-Item ./tests/test-markdown.md | Show-TextMate -Verbose -Show-TextMate -Path ./tests/test-markdown.md -Verbose +Show-TextMate -Path ./tests/test-markdown.md #-Verbose +Show-TextMate -Path ./tests/test-markdown.md -Alternate #-Verbose Pop-Location diff --git a/src/Commands/ShowTextMateCmdlet.cs b/src/Commands/ShowTextMateCmdlet.cs index 0091d19..500f7ec 100644 --- a/src/Commands/ShowTextMateCmdlet.cs +++ b/src/Commands/ShowTextMateCmdlet.cs @@ -24,7 +24,8 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter( Mandatory = true, ValueFromPipeline = true, - ValueFromPipelineByPropertyName = true + ValueFromPipelineByPropertyName = true, + Position = 0 )] [AllowEmptyString] [AllowNull] @@ -52,6 +53,13 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter] public SwitchParameter Stream { get; set; } + /// + /// When present, force use of the standard renderer even for Markdown grammars. + /// This can be used to preview alternate rendering behavior. + /// + [Parameter] + public SwitchParameter Alternate { get; set; } + /// /// Number of lines to process per batch when streaming (default: 1000). /// @@ -140,7 +148,7 @@ protected override void EndProcessing() { (string? token, bool asExtension) = TextMateResolver.ResolveToken(effectiveLanguage); // Process and wrap in HighlightedText - IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: Alternate.IsPresent); return renderables is null ? null @@ -167,12 +175,12 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { ? TextMateResolver.ResolveToken(Language) : (filePath.Extension, true); - if (Stream.IsPresent) { + if (Stream.IsPresent) { // Streaming mode - yield HighlightedText objects directly from processor WriteVerbose($"Streaming file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}, batch size: {BatchSize}"); // Direct passthrough - processor returns HighlightedText now - foreach (HighlightedText result in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension)) { + foreach (HighlightedText result in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension, Alternate.IsPresent)) { yield return result; } } @@ -181,7 +189,7 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); string[] lines = File.ReadAllLines(filePath.FullName); - IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: Alternate.IsPresent); if (renderables is not null) { yield return new HighlightedText { diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 92eef68..9e7ce34 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -23,7 +23,6 @@ internal static class StandardRenderer { // public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar); public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) { - StringBuilder builder = StringBuilderPool.Rent(); List rows = new(lines.Length); try { @@ -32,10 +31,15 @@ public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar string line = lines[lineIndex]; ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); ruleStack = result.RuleStack; - TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, lineIndex); - string? lineMarkup = builder.ToString(); - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); - builder.Clear(); + + if (string.IsNullOrEmpty(line)) { + rows.Add(Text.Empty); + continue; + } + + var paragraph = new Paragraph(); + TokenProcessor.ProcessTokensToParagraph(result.Tokens, line, theme, paragraph); + rows.Add(paragraph); } return [.. rows]; @@ -46,8 +50,5 @@ public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar catch (Exception ex) { throw new InvalidOperationException($"Unexpected error during rendering: {ex.Message}", ex); } - finally { - StringBuilderPool.Return(builder); - } } } diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 4b76927..5b12040 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -24,7 +24,7 @@ public static class TextMateProcessor { /// Rendered rows with syntax highlighting, or null if processing fails /// Thrown when is null /// Thrown when grammar cannot be found or processing encounters an error - public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension) { + public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, bool forceAlternate = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); if (lines.Length == 0 || lines.AllIsNullOrEmpty()) { @@ -36,8 +36,12 @@ public static class TextMateProcessor { // Resolve grammar using CacheManager which knows how to map language ids and extensions IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); - // Use optimized rendering based on grammar type - return grammar.GetName() == "Markdown" + // if alternate it will use TextMate for markdown as well. + if (grammar.GetName() == "Markdown" && forceAlternate) { + return StandardRenderer.Render(lines, theme, grammar); + } + + return (grammar.GetName() == "Markdown") ? MarkdownRenderer.Render(lines, theme, grammar, themeName) : StandardRenderer.Render(lines, theme, grammar); } @@ -147,6 +151,8 @@ public static IEnumerable ProcessLinesInBatches( ThemeName themeName, string grammarId, bool isExtension = false, + bool forceAlternate = false, + bool useMarkdownLayout = false, IProgress<(int batchIndex, long linesProcessed)>? progress = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); @@ -163,7 +169,11 @@ public static IEnumerable ProcessLinesInBatches( (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); // Resolve grammar using CacheManager which knows how to map language ids and extensions IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); - bool useMarkdownRenderer = grammar.GetName() == "Markdown"; + bool useMarkdownRenderer = grammar.GetName() == "Markdown" && !forceAlternate; + // If explicitly requested, prefer the Markdown layout even when forceAlternate is used + if (grammar.GetName() == "Markdown" && useMarkdownLayout) { + useMarkdownRenderer = true; + } foreach (string? line in lines) { cancellationToken.ThrowIfCancellationRequested(); @@ -211,7 +221,7 @@ public static IEnumerable ProcessLinesInBatches( /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); + public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, false, false, null, CancellationToken.None); /// /// Helper to stream a file by reading lines lazily and processing them in batches. @@ -235,15 +245,17 @@ public static IEnumerable ProcessFileInBatches( ThemeName themeName, string grammarId, bool isExtension = false, + bool forceAlternate = false, + bool useMarkdownLayout = false, IProgress<(int batchIndex, long linesProcessed)>? progress = null, CancellationToken cancellationToken = default) { return !File.Exists(filePath) ? throw new FileNotFoundException(filePath) - : ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension, progress, cancellationToken); + : ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension, forceAlternate, useMarkdownLayout, progress, cancellationToken); } /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, null, CancellationToken.None); + public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, false, false, null, CancellationToken.None); } diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index d28bdc4..37fc4ef 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -186,6 +186,35 @@ public static void WriteTokenOptimized( } } + /// + /// Processes tokens and appends their text into the provided Paragraph using Spectre styles. + /// This avoids building markup strings and lets Spectre handle rendering directly. + /// + public static void ProcessTokensToParagraph( + IToken[] tokens, + string line, + Theme theme, + Paragraph paragraph, + bool escapeMarkup = true) { + + foreach (IToken token in tokens) { + int startIndex = Math.Min(token.StartIndex, line.Length); + int endIndex = Math.Min(token.EndIndex, line.Length); + if (startIndex >= endIndex) continue; + + string text = line[startIndex..endIndex]; + Style? style = GetStyleForScopes(token.Scopes, theme); + + // Paragraph.Append does not interpret Spectre markup, so no escaping is necessary. + if (style is not null) { + paragraph.Append(text, style); + } + else { + paragraph.Append(text, Style.Plain); + } + } + } + /// /// Returns a cached Style for the given scopes and theme. Returns null for default/no-style. /// diff --git a/src/Rendering/BlockRenderer.cs b/src/Rendering/BlockRenderer.cs index 7182e97..f47090f 100644 --- a/src/Rendering/BlockRenderer.cs +++ b/src/Rendering/BlockRenderer.cs @@ -2,6 +2,7 @@ using Markdig.Syntax; using Markdig.Syntax.Inlines; using PSTextMate.Utilities; +using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; @@ -24,17 +25,19 @@ internal static class BlockRenderer { public static IEnumerable RenderBlock(Block block, Theme theme, ThemeName themeName) { return block switch { // Special handling for paragraphs that contain only an image - ParagraphBlock paragraph when MarkdownPatterns.IsStandaloneImage(paragraph) - => RenderStandaloneImage(paragraph, theme) is IRenderable r ? new[] { r } : [], + // Return the image renderable followed by an explicit blank row so the image + // and the safety padding are separate renderables (not inside the same widget). + ParagraphBlock paragraph when MarkdownPatterns.IsStandaloneImage(paragraph) => + RenderStandaloneImage(paragraph, theme) is IRenderable r ? new[] { r, Text.NewLine } : [], // Use renderers that build Spectre.Console objects directly HeadingBlock heading => [HeadingRenderer.Render(heading, theme)], ParagraphBlock paragraph - => [ParagraphRenderer.Render(paragraph, theme)], + => ParagraphRenderer.Render(paragraph, theme), // Returns IEnumerable ListBlock list => ListRenderer.Render(list, theme), - Table table + Markdig.Extensions.Tables.Table table => TableRenderer.Render(table, theme) is IRenderable t ? [t] : [], FencedCodeBlock fencedCode => CodeBlockRenderer.RenderFencedCodeBlock(fencedCode, theme, themeName) is IRenderable fc ? [fc] : [], diff --git a/src/Rendering/HtmlBlockRenderer.cs b/src/Rendering/HtmlBlockRenderer.cs index 5def870..f8d156c 100644 --- a/src/Rendering/HtmlBlockRenderer.cs +++ b/src/Rendering/HtmlBlockRenderer.cs @@ -56,9 +56,9 @@ private static List ExtractHtmlLines(HtmlBlock htmlBlock) { /// Creates a fallback HTML panel when syntax highlighting fails. /// private static Panel CreateFallbackHtmlPanel(List htmlLines) { - string? htmlText = Markup.Escape(string.Join("\n", htmlLines)); - - return new Panel(new Markup(htmlText)) + string htmlText = string.Join("\n", htmlLines); + var text = new Text(htmlText, Style.Plain); + return new Panel(text) .Border(BoxBorder.Rounded) .Header("html", Justify.Left); } diff --git a/src/Rendering/ImageBlockRenderer.cs b/src/Rendering/ImageBlockRenderer.cs index 65125c3..de0a9f8 100644 --- a/src/Rendering/ImageBlockRenderer.cs +++ b/src/Rendering/ImageBlockRenderer.cs @@ -74,8 +74,9 @@ internal static class ImageBlockRenderer { return null; } - // Create a captioned text panel - Panel captionPanel = new Panel(new Markup(caption.EscapeMarkup())) + // Create a captioned text panel using Text to avoid markup parsing + var captionText = new Text(caption ?? string.Empty, Style.Plain); + Panel captionPanel = new Panel(captionText) .Border(BoxBorder.None) .Padding(0, 1); // Padding on sides @@ -99,13 +100,12 @@ internal static class ImageBlockRenderer { } // Create caption text - var captionText = new Markup(caption.EscapeMarkup()); + var captionText2 = new Text(caption ?? string.Empty, Style.Plain); // Arrange vertically using Rows - // This is how you embed SixelImage (or any IRenderable) vertically return new Rows( imageRenderable, - new Padder(captionText, new Padding(0, 1)) // Padding above caption + new Padder(captionText2, new Padding(0, 1)) // Padding above caption ); } @@ -126,12 +126,12 @@ internal static class ImageBlockRenderer { Grid grid = new Grid() .AddColumn(new GridColumn { NoWrap = false }) - .AddRow(new Markup(topCaption?.EscapeMarkup() ?? "")); + .AddRow(new Text(topCaption ?? string.Empty, Style.Plain)); grid.AddRow(imageRenderable); if (!string.IsNullOrEmpty(bottomCaption)) { - grid.AddRow(new Markup(bottomCaption.EscapeMarkup())); + grid.AddRow(new Text(bottomCaption, Style.Plain)); } return grid; @@ -153,12 +153,12 @@ public static Table RenderImageInTable( .AddColumn("Caption"); if (imageRenderable is not null) { - table.AddRow(imageRenderable, new Markup(caption.EscapeMarkup())); + table.AddRow(imageRenderable, new Text(caption ?? string.Empty, Style.Plain)); } else { table.AddRow( - new Markup($"[grey]Image failed to load: {imageUrl}[/]"), - new Markup(caption.EscapeMarkup()) + new Text($"Image failed to load: {imageUrl}", new Style(Color.Grey)), + new Text(caption ?? string.Empty, Style.Plain) ); } diff --git a/src/Rendering/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs index 82312ed..7d5c31f 100644 --- a/src/Rendering/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -83,6 +83,8 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW int defaultMaxHeight = maxHeight ?? 30; // Default to ~30 lines high if (TryCreateSixelImage(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { + // Return the sixel image directly. The caller may append an explicit Text.NewLine + // so it renders as a separate row (avoids embedding the blank row inside the same widget). return sixelImage; } else { @@ -271,10 +273,10 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma /// Alternative text for the image /// URL or path to the image /// A markup string representing the image as a link - private static Markup CreateImageFallback(string altText, string imageUrl) { - string? linkText = $"🖼️ Image: {altText.EscapeMarkup()}"; - string? linkMarkup = $"[blue link={imageUrl.EscapeMarkup()}]{linkText}[/]"; - return new Markup(linkMarkup); + private static Text CreateImageFallback(string altText, string imageUrl) { + string linkText = $"🖼️ Image: {altText}"; + var style = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + return new Text(linkText, style); } /// @@ -289,10 +291,12 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im var fileInfo = new FileInfo(localPath); string? sizeText = fileInfo.Exists ? $" ({fileInfo.Length / 1024:N0} KB)" : ""; - var content = new Markup($"🖼️ [blue link={imageUrl.EscapeMarkup()}]{altText.EscapeMarkup()}[/]{sizeText}"); - - return new Panel(content) - .Header("[grey]Image (Sixel not available)[/]") + // Build a text-based content with clickable link style + string display = $"🖼️ {altText}{sizeText}"; + var linkStyle = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + var text = new Text(display, linkStyle); + return new Panel(text) + .Header("Image (Sixel not available)") .Border(BoxBorder.Rounded) .BorderColor(Color.Grey); } @@ -307,10 +311,10 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im /// Alternative text for the image /// URL or path to the image /// A markup string representing the image as a link - private static Markup CreateImageFallbackInline(string altText, string imageUrl) { - string? linkText = $"🖼️ {altText.EscapeMarkup()}"; - string? linkMarkup = $"[blue link={imageUrl.EscapeMarkup()}]{linkText}[/]"; - return new Markup(linkMarkup); + private static Text CreateImageFallbackInline(string altText, string imageUrl) { + string display = $"🖼️ {altText}"; + var style = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + return new Text(display, style); } /// diff --git a/src/Rendering/ListRenderer.cs b/src/Rendering/ListRenderer.cs index 1243aa2..dfb8745 100644 --- a/src/Rendering/ListRenderer.cs +++ b/src/Rendering/ListRenderer.cs @@ -105,14 +105,42 @@ private static List AppendListItemContent(Paragraph paragraph, List } /// - /// Processes inline content and appends it directly to the paragraph with proper styling. - /// This method builds Text objects directly instead of markup strings. + /// Processes inline content and builds markup for list items. /// private static void AppendInlineContent(Paragraph paragraph, ContainerInline? inlines, Theme theme) { if (inlines is null) return; - // Skip LineBreakInline for list items; Rows handles separation between list entries - ParagraphRenderer.ProcessInlineElements(paragraph, inlines, theme, skipLineBreaks: true); + foreach (Inline inline in new List(inlines)) { + switch (inline) { + case LiteralInline literal: + string literalText = literal.Content.ToString(); + if (!string.IsNullOrEmpty(literalText)) { + paragraph.Append(literalText, Style.Plain); + } + break; + + case CodeInline code: + paragraph.Append(code.Content, Style.Plain); + break; + + case LinkInline link when !link.IsImage: + string linkText = ExtractInlineText(link); + if (string.IsNullOrEmpty(linkText)) { + linkText = link.Url ?? ""; + } + var linkStyle = new Style(Color.Blue, null, Decoration.Underline, link.Url); + paragraph.Append(linkText, linkStyle); + break; + + case LineBreakInline: + // Skip line breaks in list items + break; + + default: + paragraph.Append(ExtractInlineText(inline), Style.Plain); + break; + } + } } /// diff --git a/src/Rendering/ParagraphRenderer.cs b/src/Rendering/ParagraphRenderer.cs index cf9819d..eb77a32 100644 --- a/src/Rendering/ParagraphRenderer.cs +++ b/src/Rendering/ParagraphRenderer.cs @@ -15,6 +15,7 @@ namespace PSTextMate.Rendering; /// /// Paragraph renderer that builds Spectre.Console objects directly instead of markup strings. +/// Uses Text widgets instead of Paragraph to avoid extra spacing in terminal output. /// This eliminates VT escaping issues and avoids double-parsing overhead. /// internal static partial class ParagraphRenderer { @@ -22,41 +23,39 @@ internal static partial class ParagraphRenderer { private static readonly string[] LinkScope = ["markup.underline.link"]; /// - /// Renders a paragraph block by building Spectre.Console Paragraph objects directly. - /// This approach eliminates VT escaping issues and improves performance. + /// Renders a paragraph block by building Text objects with proper Style including link parameter. + /// Avoids Paragraph widget spacing AND markup parsing overhead. + /// Uses Style(foreground, background, decoration, link) for clickable links and styled code. /// /// The paragraph block to render /// Theme for styling - /// Rendered paragraph as a Paragraph object with proper inline styling - public static IRenderable Render(ParagraphBlock paragraph, Theme theme) { - var spectreParagraph = new Paragraph(); + /// Text segments with proper styling + public static IEnumerable Render(ParagraphBlock paragraph, Theme theme) { + var segments = new List(); if (paragraph.Inline is not null) { - ProcessInlineElements(spectreParagraph, paragraph.Inline, theme); + BuildTextSegments(segments, paragraph.Inline, theme); } - return spectreParagraph; + return segments; } /// - /// Processes inline elements and adds them directly to the Paragraph with appropriate styling. + /// Builds Text segments from inline elements with proper Style objects. + /// Accumulates plain text and flushes when style changes (code, links). /// - /// Target Spectre paragraph to append to - /// Markdig inline container - /// Theme for styling - /// If true, skips LineBreakInline (used for list items where Rows handles spacing) - internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline inlines, Theme theme, bool skipLineBreaks = false) { - // Convert to list to allow index-based access for checking trailing line breaks + private static void BuildTextSegments(List segments, ContainerInline inlines, Theme theme, bool skipLineBreaks = false) { + var paragraph = new Paragraph(); + bool addedAny = false; + List inlineList = [.. inlines]; for (int i = 0; i < inlineList.Count; i++) { Inline inline = inlineList[i]; - // Check if this is a trailing line break (last element or followed only by other line breaks) bool isTrailingLineBreak = false; if (inline is LineBreakInline && i < inlineList.Count) { isTrailingLineBreak = true; - // Check if there are any non-LineBreakInline elements after this for (int j = i + 1; j < inlineList.Count; j++) { if (inlineList[j] is not LineBreakInline) { isTrailingLineBreak = false; @@ -66,294 +65,157 @@ internal static void ProcessInlineElements(Paragraph paragraph, ContainerInline } switch (inline) { - case LiteralInline literal: - string literalText = literal.Content.ToString(); - // Skip empty literals to avoid extra blank lines - if (string.IsNullOrEmpty(literalText)) { - break; - } - - // Check for username patterns like @username - if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) { - foreach (TextSegment segment in segments) { - if (segment.IsUsername) { - // Create clickable username link (you could customize the URL pattern) - var usernameStyle = new Style( - foreground: Color.Blue, - decoration: Decoration.Underline, - link: $"https://github.com/{segment.Text.TrimStart('@')}" - ); - paragraph.Append(segment.Text, usernameStyle); + case LiteralInline literal: { + string literalText = literal.Content.ToString(); + if (!string.IsNullOrEmpty(literalText)) { + if (TryParseUsernameLinks(literalText, out TextSegment[]? usernameSegments)) { + foreach (TextSegment segment in usernameSegments) { + if (segment.IsUsername) { + var usernameStyle = new Style(Color.Blue, null, Decoration.Underline, $"https://github.com/{segment.Text.TrimStart('@')}"); + paragraph.Append(segment.Text, usernameStyle); + addedAny = true; + } + else { + paragraph.Append(segment.Text, Style.Plain); + addedAny = true; + } + } } else { - paragraph.Append(segment.Text, Style.Plain); + paragraph.Append(literalText, Style.Plain); + addedAny = true; } } } - else { - paragraph.Append(literalText, Style.Plain); - } break; - case EmphasisInline emphasis: - ProcessEmphasisInline(paragraph, emphasis, theme); + case EmphasisInline emphasis: { + Decoration decoration = GetEmphasisDecoration(emphasis.DelimiterCount); + var emphasisStyle = new Style(null, null, decoration); + + foreach (Inline emphInline in emphasis) { + switch (emphInline) { + case LiteralInline lit: + paragraph.Append(lit.Content.ToString(), emphasisStyle); + addedAny = true; + break; + case CodeInline codeInline: + paragraph.Append(codeInline.Content, GetCodeStyle(theme)); + addedAny = true; + break; + case LinkInline linkInline: + // Build link style and include emphasis decoration + string linkText = ExtractInlineText(linkInline); + if (string.IsNullOrEmpty(linkText)) linkText = linkInline.Url ?? ""; + Style baseLink = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); + var combined = new Style(baseLink.Foreground, baseLink.Background, baseLink.Decoration | decoration | Decoration.Underline, linkInline.Url); + paragraph.Append(linkText, combined); + addedAny = true; + break; + default: + paragraph.Append(ExtractInlineText(emphInline), emphasisStyle); + addedAny = true; + break; + } + } + } break; case CodeInline code: - ProcessCodeInline(paragraph, code, theme); + paragraph.Append(code.Content, GetCodeStyle(theme)); + addedAny = true; break; case LinkInline link: - ProcessLinkInline(paragraph, link, theme); + ProcessLinkAsText(paragraph, link, theme); + addedAny = true; break; case AutolinkInline autoLink: - ProcessAutoLinkInline(paragraph, autoLink, theme); - break; - - case TaskList taskList: - // TaskList items are handled at the list level, skip here + ProcessAutoLinkAsText(paragraph, autoLink, theme); + addedAny = true; break; case LineBreakInline: - // Skip trailing line breaks to avoid double-spacing with Rows container - // Also skip line breaks in lists (Rows handles spacing) if (!skipLineBreaks && !isTrailingLineBreak) { paragraph.Append("\n", Style.Plain); + addedAny = true; } break; case HtmlInline html: - // For HTML inlines, just extract the text content - string htmlText = html.Tag ?? ""; - paragraph.Append(htmlText, Style.Plain); + paragraph.Append(html.Tag ?? "", Style.Plain); + addedAny = true; + break; + + case TaskList: break; default: - // Fallback for unknown inline types - just write text as-is - string defaultText = ExtractInlineText(inline); - paragraph.Append(defaultText, Style.Plain); + paragraph.Append(ExtractInlineText(inline), Style.Plain); + addedAny = true; break; } } + + if (addedAny) { + segments.Add(paragraph); + } } /// - /// Processes emphasis (bold/italic) inline elements while preserving nested links. + /// Process link as Text with Style including link parameter for clickability. /// - private static void ProcessEmphasisInline(Paragraph paragraph, EmphasisInline emphasis, Theme theme) { - // Determine emphasis style based on delimiter count - Decoration decoration = emphasis.DelimiterCount switch { - 1 => Decoration.Italic, // Single * or _ - 2 => Decoration.Bold, // Double ** or __ - 3 => Decoration.Bold | Decoration.Italic, // Triple *** or ___ - _ => Decoration.None - }; + private static void ProcessLinkAsText(Paragraph paragraph, LinkInline link, Theme theme) { + if (link.IsImage) { + string altText = ExtractInlineText(link); + if (string.IsNullOrEmpty(altText)) altText = "Image"; + string imageLinkText = $"🖼️ {altText}"; + var style = new Style(Color.Blue, null, Decoration.Underline, link.Url); + paragraph.Append(imageLinkText, style); + return; + } - // Process children while applying emphasis decoration - ProcessInlineElementsWithDecoration(paragraph, emphasis, decoration, theme); + string linkText = ExtractInlineText(link); + if (string.IsNullOrEmpty(linkText)) linkText = link.Url ?? ""; + + Style linkStyle = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); + // Create new style with link parameter + var styledLink = new Style(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline, link.Url); + paragraph.Append(linkText, styledLink); } /// - /// Processes inline elements while applying a decoration (like bold/italic) to text elements, - /// but preserving special handling for links and other complex inlines. + /// Process autolink as Text with Style including link parameter. /// - private static void ProcessInlineElementsWithDecoration(Paragraph paragraph, ContainerInline container, Decoration decoration, Theme theme) { - foreach (Inline inline in container) { - switch (inline) { - case LiteralInline literal: - string literalText = literal.Content.ToString(); - var emphasisStyle = new Style(decoration: decoration); - - // Check for username patterns like @username - if (TryParseUsernameLinks(literalText, out TextSegment[]? segments)) { - foreach (TextSegment segment in segments) { - if (segment.IsUsername) { - // Create clickable username link with emphasis - var usernameStyle = new Style( - foreground: Color.Blue, - decoration: Decoration.Underline | decoration, // Combine with emphasis - link: $"https://github.com/{segment.Text.TrimStart('@')}" - ); - paragraph.Append(segment.Text, usernameStyle); - } - else { - paragraph.Append(segment.Text, emphasisStyle); - } - } - } - else { - paragraph.Append(literalText, emphasisStyle); - } - break; - - case LinkInline link: - // Process link but apply emphasis decoration to the link text - ProcessLinkInlineWithDecoration(paragraph, link, decoration, theme); - break; - - case CodeInline code: - // Code should not inherit emphasis decoration - ProcessCodeInline(paragraph, code, theme); - break; - - case EmphasisInline nestedEmphasis: - // Handle nested emphasis by combining decorations - Decoration nestedDecoration = nestedEmphasis.DelimiterCount switch { - 1 => Decoration.Italic, - 2 => Decoration.Bold, - 3 => Decoration.Bold | Decoration.Italic, - _ => Decoration.None - }; - ProcessInlineElementsWithDecoration(paragraph, nestedEmphasis, decoration | nestedDecoration, theme); - break; - - case LineBreakInline: - paragraph.Append("\n", Style.Plain); - break; + private static void ProcessAutoLinkAsText(Paragraph paragraph, AutolinkInline autoLink, Theme theme) { + string url = autoLink.Url ?? ""; + if (string.IsNullOrEmpty(url)) return; - default: - // Fallback - apply emphasis to extracted text - string defaultText = ExtractInlineText(inline); - paragraph.Append(defaultText, new Style(decoration: decoration)); - break; - } - } + Style linkStyle = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); + var styledLink = new Style(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline, url); + paragraph.Append(url, styledLink); } /// - /// Processes a link inline while applying emphasis decoration. + /// Get link style from theme. /// - private static void ProcessLinkInlineWithDecoration(Paragraph paragraph, LinkInline link, Decoration emphasisDecoration, Theme theme) { - // Use link text if available, otherwise use URL - string linkText = ExtractInlineText(link); - if (string.IsNullOrEmpty(linkText)) { - linkText = link.Url ?? ""; - } - - // Use cached style if available - Style? linkStyleBase = TokenProcessor.GetStyleForScopes(LinkScope, theme); - Color? foregroundColor = linkStyleBase?.Foreground ?? Color.Blue; - Color? backgroundColor = linkStyleBase?.Background ?? null; - Decoration baseDecoration = linkStyleBase is not null ? linkStyleBase.Decoration : Decoration.None; - Decoration linkDecoration = baseDecoration | Decoration.Underline | emphasisDecoration; - - var linkStyle = new Style(foregroundColor, backgroundColor, linkDecoration, link.Url); - paragraph.Append(linkText, linkStyle); - } + private static Style? GetLinkStyle(Theme theme) + => TokenProcessor.GetStyleForScopes(LinkScope, theme); /// - /// Processes inline code elements with syntax highlighting. + /// Get code style from theme. /// - private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Theme theme) { - // Get theme colors for inline code + private static Style GetCodeStyle(Theme theme) { string[] codeScopes = ["markup.inline.raw"]; (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( new MarkdownToken(codeScopes), theme); - // Create code styling Color? foregroundColor = codeFg != -1 ? StyleHelper.GetColor(codeFg, theme) : Color.Yellow; Color? backgroundColor = codeBg != -1 ? StyleHelper.GetColor(codeBg, theme) : Color.Grey11; Decoration decoration = StyleHelper.GetDecoration(codeFs); - var codeStyle = new Style(foregroundColor, backgroundColor, decoration); - paragraph.Append(code.Content, codeStyle); - } - - /// - /// Processes link inline elements with clickable links using Spectre.Console Style with link parameter. - /// Also handles images (when IsImage is true) by delegating to ImageRenderer. - /// - private static void ProcessLinkInline(Paragraph paragraph, LinkInline link, Theme theme) { - // Check if this is an image (![alt](url) syntax) - if (link.IsImage) { - // Extract alt text from the link - string altText = ExtractInlineText(link); - if (string.IsNullOrEmpty(altText)) { - altText = "Image"; - } - - // Render the image using ImageRenderer (Sixel support) - IRenderable imageRenderable = ImageRenderer.RenderImageInline(altText, link.Url ?? "", maxWidth: null, maxHeight: null); - - // Note: Can't directly append IRenderable to Paragraph, so we need to handle this differently - // For now, images inside paragraphs will use fallback link representation - // TODO: Consider restructuring to support embedded IRenderable in Paragraph - if (imageRenderable is Markup imageMarkup) { - // If it's a fallback Markup, we can append it - string markupText = imageMarkup.ToString() ?? ""; - paragraph.Append(markupText, Style.Plain); - } - else { - // It's a SixelImage - can't embed in Paragraph inline - // Fall back to link representation - string imageLinkText = $"🖼️ {altText}"; - var imageLinkStyle = new Style( - foreground: Color.Blue, - decoration: Decoration.Underline, - link: link.Url - ); - paragraph.Append(imageLinkText, imageLinkStyle); - } - return; - } - - // Regular link handling - // Use link text if available, otherwise use URL - string linkText = ExtractInlineText(link); - if (string.IsNullOrEmpty(linkText)) { - linkText = link.Url ?? ""; - } - - // Get theme colors for links - string[] linkScopes = ["markup.underline.link"]; - (int linkFg, int linkBg, FontStyle linkFs) = TokenProcessor.ExtractThemeProperties( - new MarkdownToken(linkScopes), theme); - - // Create link styling with clickable URL - Color? foregroundColor = linkFg != -1 ? StyleHelper.GetColor(linkFg, theme) : Color.Blue; - Color? backgroundColor = linkBg != -1 ? StyleHelper.GetColor(linkBg, theme) : null; - Decoration decoration = StyleHelper.GetDecoration(linkFs) | Decoration.Underline; - - // Create style with link parameter for clickable links - var linkStyle = new Style( - foreground: foregroundColor, - background: backgroundColor, - decoration: decoration, - link: link.Url // This makes it clickable! - ); - - paragraph.Append(linkText, linkStyle); - } - - /// - /// Processes Markdig AutolinkInline (URLs/emails detected by UseAutoLinks). - /// - private static void ProcessAutoLinkInline(Paragraph paragraph, AutolinkInline autoLink, Theme theme) { - string url = autoLink.Url ?? string.Empty; - if (string.IsNullOrEmpty(url)) { - // Nothing to render - return; - } - - // Get theme colors for links - string[] linkScopes = ["markup.underline.link"]; - (int linkFg, int linkBg, FontStyle linkFs) = TokenProcessor.ExtractThemeProperties( - new MarkdownToken(linkScopes), theme); - - Color? foregroundColor = linkFg != -1 ? StyleHelper.GetColor(linkFg, theme) : Color.Blue; - Color? backgroundColor = linkBg != -1 ? StyleHelper.GetColor(linkBg, theme) : null; - Decoration decoration = StyleHelper.GetDecoration(linkFs) | Decoration.Underline; - - var linkStyle = new Style( - foreground: foregroundColor, - background: backgroundColor, - decoration: decoration, - link: url - ); - - // For autolinks, the visible text is the URL itself - paragraph.Append(url, linkStyle); + return new Style(foregroundColor, backgroundColor, decoration); } /// @@ -370,6 +232,23 @@ private static string ExtractInlineText(Inline inline) { } } + /// + /// Determine decoration to use for emphasis based on delimiter count and environment fallback. + /// If environment variable `PSTEXTMATE_EMPHASIS_FALLBACK` == "underline" then use underline + /// for single-asterisk emphasis so italics are visible on terminals that do not support italic. + /// + private static Decoration GetEmphasisDecoration(int delimiterCount) { + // Read once per call; environment lookups are cheap here since rendering isn't hot inner loop + string? fallback = Environment.GetEnvironmentVariable("PSTEXTMATE_EMPHASIS_FALLBACK"); + + return delimiterCount switch { + 1 => string.Equals(fallback, "underline", StringComparison.OrdinalIgnoreCase) ? Decoration.Underline : Decoration.Italic, + 2 => Decoration.Bold, + 3 => Decoration.Bold | Decoration.Italic, + _ => Decoration.None, + }; + } + /// /// Represents a text segment that may or may not be a username link. /// diff --git a/src/Rendering/QuoteRenderer.cs b/src/Rendering/QuoteRenderer.cs index 948c853..25c0257 100644 --- a/src/Rendering/QuoteRenderer.cs +++ b/src/Rendering/QuoteRenderer.cs @@ -20,7 +20,8 @@ internal static class QuoteRenderer { public static IRenderable Render(QuoteBlock quote, Theme theme) { string quoteText = ExtractQuoteText(quote, theme); - return new Panel(new Markup(Markup.Escape(quoteText))) + var text = new Text(quoteText, Style.Plain); + return new Panel(text) .Border(BoxBorder.Heavy) .Header("quote", Justify.Left); } diff --git a/tests/Integration/RenderingIntegrationTests.cs b/tests/Integration/RenderingIntegrationTests.cs new file mode 100644 index 0000000..167e22e --- /dev/null +++ b/tests/Integration/RenderingIntegrationTests.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; +using FluentAssertions; +using Xunit; + +namespace PSTextMate.Tests.Integration; + +public class RenderingIntegrationTests +{ + private static string RunRepScript() + { + var psi = new ProcessStartInfo() + { + FileName = "pwsh", + Arguments = "-NoProfile -File .\\rep.ps1", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = System.IO.Path.GetFullPath(".") + }; + + using var p = Process.Start(psi)!; + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(30000); + + if (p.ExitCode != 0) + { + throw new Exception($"rep.ps1 failed: exit={p.ExitCode}\n{stderr}"); + } + + return stdout; + } + + [Fact] + public void RepScript_Includes_InlineCode_And_Links_And_ImageText() + { + string output = RunRepScript(); + + output.Should().Contain("Inline code: Write-Host"); + output.Should().Contain("GitHub"); + output.Should().Contain("Blue styled link"); + output.Should().Contain("xkcd git"); + } +} diff --git a/tests/test-markdown.md b/tests/test-markdown.md index 367f3e5..7c34fc8 100644 --- a/tests/test-markdown.md +++ b/tests/test-markdown.md @@ -80,7 +80,7 @@ public static bool IsSupportedFile(string file) { ## Images -[xkcd git](../assets/git_commit.png) +![xkcd git](../assets/git_commit.png) ## Horizontal Rule diff --git a/tools/analyze-block-lines.cs b/tools/analyze-block-lines.cs new file mode 100644 index 0000000..6625432 --- /dev/null +++ b/tools/analyze-block-lines.cs @@ -0,0 +1,63 @@ +#:package Markdig.Signed@0.38.0 +using Markdig; +using Markdig.Syntax; +// dotnet run ./analyze-block-lines.cs ../tests/test-markdown.md +string path = args.Length > 0 ? args[0] : "tests/test-markdown.md"; +string markdown = File.ReadAllText(path); + +MarkdownPipeline pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseTaskLists() + .UsePipeTables() + .UseAutoLinks() + .EnableTrackTrivia() + .Build(); + +MarkdownDocument document = Markdown.Parse(markdown, pipeline); + +Console.WriteLine($"Analyzing blocks in: {path}\n"); + +Block? previousBlock = null; +int totalGapLines = 0; + +for (int i = 0; i < document.Count; i++) { + Block block = document[i]; + int endLine = GetBlockEndLine(block, markdown); + + if (previousBlock is not null) { + int prevEndLine = GetBlockEndLine(previousBlock, markdown); + int gap = block.Line - prevEndLine - 1; + totalGapLines += Math.Max(0, gap); + Console.WriteLine($" Gap: {prevEndLine} -> {block.Line} = {gap} blank lines"); + } + + Console.WriteLine($"[{i,2}] {block.GetType().Name,-20} Line {block.Line,3} -> {endLine,3} Span: {block.Span.Start}-{block.Span.End}"); + previousBlock = block; +} + +int sourceLines = markdown.Split('\n').Length; +Console.WriteLine($"\nSource lines: {sourceLines}"); +Console.WriteLine($"Document blocks: {document.Count}"); +Console.WriteLine($"Total gap lines: {totalGapLines}"); +Console.WriteLine($"Expected rendered lines (blocks + gaps): {document.Count + totalGapLines}"); + +static int GetBlockEndLine(Block block, string md) { + // For container blocks, recursively find the last child's end line + if (block is ContainerBlock container && container.Count > 0) { + return GetBlockEndLine(container[^1], md); + } + // For fenced code blocks: opening fence + content lines + closing fence + if (block is FencedCodeBlock fenced && fenced.Lines.Count > 0) { + return block.Line + fenced.Lines.Count + 1; + } + // Count newlines within the block's span (excluding the final newline which separates blocks) + // The span typically includes the trailing newline, so we stop before Span.End + int endPosition = Math.Min(block.Span.End - 1, md.Length - 1); + int newlineCount = 0; + for (int i = block.Span.Start; i <= endPosition; i++) { + if (md[i] == '\n') { + newlineCount++; + } + } + return block.Line + newlineCount; +} diff --git a/tools/diagnostic-trivia.cs b/tools/diagnostic-trivia.cs new file mode 100644 index 0000000..418fcfe --- /dev/null +++ b/tools/diagnostic-trivia.cs @@ -0,0 +1,98 @@ +#:package Markdig.Signed@0.38.0 +using Markdig; +using Markdig.Renderers.Roundtrip; +using Markdig.Syntax; +// dotnet run ./diagnostic-trivia.cs ../tests/test-markdown.md +static int CountLines(string content) { + if (string.IsNullOrEmpty(content)) { + return 0; + } + + int count = 0; + using var reader = new StringReader(content.Replace("\r\n", "\n").Replace('\r', '\n')); + while (reader.ReadLine() is not null) { + count++; + } + + return count; +} + +string? firstArg = args.FirstOrDefault(a => !a.StartsWith("--", StringComparison.OrdinalIgnoreCase)); +bool analyzeAll = args.Any(a => string.Equals(a, "--all", StringComparison.OrdinalIgnoreCase)); +IEnumerable targets = []; + +if (analyzeAll) { + string testDir = Path.Combine("..", "tests"); + if (!Directory.Exists(testDir)) { + testDir = "tests"; + } + + if (!Directory.Exists(testDir)) { + Console.Error.WriteLine($"Error: Could not find tests directory at {testDir}"); + return; + } + + targets = Directory.GetFiles(testDir, "*.md", SearchOption.AllDirectories); +} +else if (!string.IsNullOrEmpty(firstArg)) { + targets = [firstArg]; +} +else { + string defaultPath = Path.Combine("..", "tests", "test-markdown.md"); + if (!File.Exists(defaultPath)) { + defaultPath = "tests/test-markdown.md"; + } + targets = [defaultPath]; +} + +int totalSourceLines = 0; +int totalRoundtripLines = 0; +int processedFiles = 0; + +foreach (string path in targets) { + if (!File.Exists(path)) { + Console.Error.WriteLine($"Error: Could not find markdown file at {path}"); + continue; + } + + string markdown = File.ReadAllText(path); + int sourceLines = CountLines(markdown); + + MarkdownDocument document = Markdown.Parse(markdown, trackTrivia: true); + using var writer = new StringWriter(); + var roundtrip = new RoundtripRenderer(writer); + roundtrip.Write(document); + int roundtripLines = CountLines(writer.ToString()); + + totalSourceLines += sourceLines; + totalRoundtripLines += roundtripLines; + processedFiles++; + + Console.WriteLine($"Analyzing: {Path.GetFullPath(path)}"); + Console.WriteLine($"Source line count: {sourceLines}"); + Console.WriteLine($"Roundtrip line count: {roundtripLines}"); + Console.WriteLine($"Delta: {roundtripLines - sourceLines}\n"); + + Console.WriteLine("=== Complete Trivia Analysis (LinesBefore, LinesAfter, TriviaBefore, TriviaAfter) ===\n"); + + for (int i = 0; i < document.Count; i++) { + Block block = document[i]; + Console.WriteLine($"[{i}] {block.GetType().Name,-20} Line {block.Line,3}"); + + if (block.LinesBefore != null && block.LinesBefore.Count > 0) { + Console.WriteLine($" LinesBefore.Count: {block.LinesBefore.Count}"); + } + + if (block.LinesAfter != null && block.LinesAfter.Count > 0) { + Console.WriteLine($" LinesAfter.Count: {block.LinesAfter.Count}"); + } + } +} + +if (processedFiles > 0) { + Console.WriteLine("=== Summary ==="); + Console.WriteLine($"Files analyzed: {processedFiles}"); + Console.WriteLine($"Total source lines: {totalSourceLines}"); + Console.WriteLine($"Total roundtrip lines: {totalRoundtripLines}"); + Console.WriteLine($"Total delta: {totalRoundtripLines - totalSourceLines}"); +} diff --git a/tools/test-line-calc.cs b/tools/test-line-calc.cs new file mode 100644 index 0000000..39eeaa2 --- /dev/null +++ b/tools/test-line-calc.cs @@ -0,0 +1,51 @@ +string path = args.Length > 0 ? args[0] : "../tests/test-markdown.md"; +string markdown = File.ReadAllText(path); + +// dotnet run ./test-line-calc.cs ../tests/test-markdown.md + +var pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseTaskLists() + .UsePipeTables() + .UseAutoLinks() + .EnableTrackTrivia() + .Build(); + +var document = Markdown.Parse(markdown, pipeline); + +Block? previousBlock = null; +int totalGap = 0; + +foreach (Block block in document) { + if (previousBlock is not null) { + int previousEndLine = GetBlockEndLine(previousBlock, markdown); + int gap = block.Line - previousEndLine - 1; + totalGap += gap; + Console.WriteLine($"{previousBlock.GetType().Name,-20} ends at {previousEndLine,3} -> {block.GetType().Name,-20} at {block.Line,3} = gap {gap}"); + } + previousBlock = block; +} + +Console.WriteLine($"\nTotal blank lines from gaps: {totalGap}"); +Console.WriteLine($"Document blocks: {document.Count}"); +Console.WriteLine($"Expected output lines: {document.Count + totalGap}"); + +int GetBlockEndLine(Block block, string md) { + // For container blocks, recursively find the last child's end line + if (block is ContainerBlock container && container.Count > 0) { + return GetBlockEndLine(container[^1], md); + } + // For fenced code blocks: opening fence + content lines + closing fence + if (block is FencedCodeBlock fenced && fenced.Lines.Count > 0) { + return block.Line + fenced.Lines.Count + 1; + } + // Count newlines within the block's span to find the ending line + int endPosition = Math.Min(block.Span.End, md.Length - 1); + int newlineCount = 0; + for (int i = block.Span.Start; i <= endPosition; i++) { + if (md[i] == '\n') { + newlineCount++; + } + } + return block.Line + newlineCount; +} From 76a5356dfcf7f7b2061f953d1eae03dc67ffb2e3 Mon Sep 17 00:00:00 2001 From: trackd Date: Sun, 1 Feb 2026 22:15:59 +0100 Subject: [PATCH 13/25] =?UTF-8?q?feat(commands):=20=E2=9C=A8=20Add=20cmdle?= =?UTF-8?q?ts=20for=20testing=20and=20retrieving=20supported=20TextMate=20?= =?UTF-8?q?languages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced `Test-SupportedTextMate` cmdlet for validating language support based on file extension, language ID, or file path. * Added `Get-SupportedTextMate` cmdlet to retrieve all supported TextMate languages and their configurations. * Updated `CmdletsToExport` in the module manifest to reflect the new cmdlets. * Enhanced string manipulation methods in `StringExtensions` for optimized performance using Span operations. * Updated dependencies in the project file for improved compatibility. --- Module/PSTextMate.psd1 | 4 +- src/Commands/GetSupportedTextMate.cs | 17 ++ src/Commands/SupportCmdlets.cs | 57 ------ src/Commands/TestSupportedTextMate.cs | 65 +++++++ src/Core/TextMateProcessor.cs | 3 +- src/Core/TokenProcessor.cs | 11 +- src/PSTextMate.csproj | 4 +- src/Rendering/ImageRenderer.cs | 2 +- .../SpanOptimizedStringExtensions.cs | 169 ------------------ src/Utilities/StringExtensions.cs | 154 +++++++++++++++- src/Utilities/VTConversion.cs | 2 +- 11 files changed, 244 insertions(+), 244 deletions(-) create mode 100644 src/Commands/GetSupportedTextMate.cs delete mode 100644 src/Commands/SupportCmdlets.cs create mode 100644 src/Commands/TestSupportedTextMate.cs delete mode 100644 src/Utilities/SpanOptimizedStringExtensions.cs diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index fd0d64f..7b207f5 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -7,14 +7,14 @@ Copyright = '(c) trackd. All rights reserved.' PowerShellVersion = '7.4' CompatiblePSEditions = 'Core' - CmdletsToExport = 'Show-TextMate', 'Test-SupportedTextMate', 'Get-SupportedTextMate', 'Debug-TextMate', 'Debug-TextMateTokens','Debug-SixelSupport','Test-ImageRendering' + CmdletsToExport = 'Show-TextMate', 'Test-SupportedTextMate', 'Get-SupportedTextMate' AliasesToExport = '*' RequiredAssemblies = './lib/TextMateSharp.dll', './lib/TextMateSharp.Grammars.dll', './lib/Onigwrap.dll', 'Markdig.Signed.dll' FormatsToProcess = 'PSTextMate.format.ps1xml' RequiredModules = @( @{ ModuleName = 'PwshSpectreConsole' - ModuleVersion = '2.1.0' + ModuleVersion = '2.3.0' MaximumVersion = '2.9.9' } ) diff --git a/src/Commands/GetSupportedTextMate.cs b/src/Commands/GetSupportedTextMate.cs new file mode 100644 index 0000000..00c390e --- /dev/null +++ b/src/Commands/GetSupportedTextMate.cs @@ -0,0 +1,17 @@ +using System.Management.Automation; +using TextMateSharp.Grammars; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for retrieving all supported TextMate languages and their configurations. +/// Returns detailed information about available grammars and extensions. +/// +[OutputType(typeof(Language))] +[Cmdlet(VerbsCommon.Get, "SupportedTextMate")] +public sealed class GetSupportedTextMateCmdlet : PSCmdlet { + /// + /// Finalizes processing and outputs all supported languages. + /// + protected override void EndProcessing() => WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); +} diff --git a/src/Commands/SupportCmdlets.cs b/src/Commands/SupportCmdlets.cs deleted file mode 100644 index 0c2ca2d..0000000 --- a/src/Commands/SupportCmdlets.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Management.Automation; -using TextMateSharp.Grammars; - -namespace PSTextMate.Commands; - -/// -/// Cmdlet for testing TextMate support for languages, extensions, and files. -/// Provides validation functionality to check compatibility before processing. -/// -[Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate")] -public sealed class TestSupportedTextMateCmdlet : PSCmdlet { - /// - /// File extension to test for support (e.g., '.ps1'). - /// - [Parameter()] - public string? Extension { get; set; } - - /// - /// Language ID to test for support (e.g., 'powershell'). - /// - [Parameter()] - public string? Language { get; set; } - - /// - /// File path to test for support. - /// - [Parameter()] - public string? File { get; set; } - - /// - /// Finalizes processing and outputs support check results. - /// - protected override void EndProcessing() { - if (!string.IsNullOrEmpty(File)) { - WriteObject(TextMateExtensions.IsSupportedFile(File)); - } - if (!string.IsNullOrEmpty(Extension)) { - WriteObject(TextMateExtensions.IsSupportedExtension(Extension)); - } - if (!string.IsNullOrEmpty(Language)) { - WriteObject(TextMateLanguages.IsSupportedLanguage(Language)); - } - } -} - -/// -/// Cmdlet for retrieving all supported TextMate languages and their configurations. -/// Returns detailed information about available grammars and extensions. -/// -[OutputType(typeof(Language))] -[Cmdlet(VerbsCommon.Get, "SupportedTextMate")] -public sealed class GetSupportedTextMateCmdlet : PSCmdlet { - /// - /// Finalizes processing and outputs all supported languages. - /// - protected override void EndProcessing() => WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); -} diff --git a/src/Commands/TestSupportedTextMate.cs b/src/Commands/TestSupportedTextMate.cs new file mode 100644 index 0000000..127faa6 --- /dev/null +++ b/src/Commands/TestSupportedTextMate.cs @@ -0,0 +1,65 @@ +using System.Management.Automation; +using System.Runtime.InteropServices; +using TextMateSharp.Grammars; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for testing TextMate support for languages, extensions, and files. +/// Provides validation functionality to check compatibility before processing. +/// +[Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate", DefaultParameterSetName = "File")] +public sealed class TestSupportedTextMateCmdlet : PSCmdlet { + /// + /// File extension to test for support (e.g., '.ps1'). + /// + [Parameter( + ParameterSetName = "ExtensionSet", + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string? Extension { get; set; } + + /// + /// Language ID to test for support (e.g., 'powershell'). + /// + [Parameter( + ParameterSetName = "LanguageSet", + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string? Language { get; set; } + + /// + /// File path to test for support. + /// + [Parameter( + ParameterSetName = "FileSet", + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string? File { get; set; } + + /// + /// Finalizes processing and outputs support check results. + /// + protected override void EndProcessing() { + switch (ParameterSetName) { + case "FileSet": + FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(File)); + if (!filePath.Exists) { + WriteError(new ErrorRecord(null, "TestSupportedTextMateCmdlet", ErrorCategory.ObjectNotFound, File)); + } + WriteObject(TextMateExtensions.IsSupportedFile(filePath.FullName)); + break; + case "ExtensionSet": + WriteObject(TextMateExtensions.IsSupportedExtension(Extension!)); + break; + case "LanguageSet": + WriteObject(TextMateLanguages.IsSupportedLanguage(Language!)); + break; + default: + break; + } + } +} diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 5b12040..fcfceba 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -221,7 +221,8 @@ public static IEnumerable ProcessLinesInBatches( /// /// Backward compatibility overload without cancellation and progress support. /// - public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, false, false, null, CancellationToken.None); + public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) + => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, false, false, null, CancellationToken.None); /// /// Helper to stream a file by reading lines lazily and processing them in batches. diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index 37fc4ef..f757fc7 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -13,9 +13,6 @@ namespace PSTextMate.Core; /// Handles theme property extraction and token rendering with performance optimizations. /// internal static class TokenProcessor { - // Toggle caching via environment variable PSTEXTMATE_DISABLE_STYLECACHE=1 to disable - public static readonly bool EnableStyleCache = Environment.GetEnvironmentVariable("PSTEXTMATE_DISABLE_STYLECACHE") != "1"; - // Cache theme extraction results per (scopesKey, themeInstanceHash) private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), (int fg, int bg, FontStyle fs)> _themePropertyCache = new(); // Cache Style results per (scopesKey, themeInstanceHash) private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> _styleCache = new(); @@ -44,7 +41,7 @@ public static void ProcessTokensBatch( if (startIndex >= endIndex) continue; - ReadOnlySpan textSpan = line.SubstringAsSpan(startIndex, endIndex); + ReadOnlySpan textSpan = line.SpanSubstring(startIndex, endIndex); // Use cached Style where possible to avoid rebuilding Style objects per token Style? style = GetStyleForScopes(token.Scopes, theme); @@ -53,7 +50,7 @@ public static void ProcessTokensBatch( (int foreground, int background, FontStyle fontStyle) = (-1, -1, FontStyle.NotSet); // Use the returning API so callers can append with style consistently (prevents markup regressions) - (string processedText, Style? resolvedStyle) = WriteTokenOptimizedReturn(textSpan, style, theme, escapeMarkup); + (string processedText, Style? resolvedStyle) = WriteTokenReturn(textSpan, style, theme, escapeMarkup); builder.AppendWithStyle(resolvedStyle, processedText); } @@ -94,7 +91,7 @@ public static (int foreground, int background, FontStyle fontStyle) ExtractTheme /// where the caller appends via AppendWithStyle so that Markup escaping and concatenation /// semantics remain identical. /// - public static (string processedText, Style? style) WriteTokenOptimizedReturn( + public static (string processedText, Style? style) WriteTokenReturn( ReadOnlySpan text, Style? styleHint, Theme theme, @@ -121,7 +118,7 @@ public static (string processedText, Style? style) WriteTokenOptimizedReturn( /// Append the provided text span into the builder with optional style and optional markup escaping. /// (Existing fast-path writer retained for specialized callers.) /// - public static void WriteTokenOptimized( + public static void WriteToken( StringBuilder builder, ReadOnlySpan text, Style? style, diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index da8c07a..889740a 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/src/Rendering/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs index 7d5c31f..b0172ab 100644 --- a/src/Rendering/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -166,7 +166,7 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma // First, try the direct approach - SixelImage is in Spectre.Console namespace // but might be in different assemblies (Spectre.Console vs Spectre.Console.ImageSharp) Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); + ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console") ?? Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); // If that fails, search through loaded assemblies if (sixelImageType is null) { diff --git a/src/Utilities/SpanOptimizedStringExtensions.cs b/src/Utilities/SpanOptimizedStringExtensions.cs deleted file mode 100644 index 44954c0..0000000 --- a/src/Utilities/SpanOptimizedStringExtensions.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Text; - -namespace PSTextMate.Utilities; - -/// -/// Enhanced string manipulation methods optimized with Span operations. -/// Provides significant performance improvements for text processing scenarios. -/// -public static class SpanOptimizedStringExtensions { - /// - /// Joins string arrays using span operations for better performance. - /// Avoids multiple string allocations during concatenation. - /// - /// Array of strings to join - /// Separator character - /// Joined string - public static string JoinOptimized(this string[] values, char separator) { - if (values.Length == 0) return string.Empty; - if (values.Length == 1) return values[0] ?? string.Empty; - - // Calculate total capacity to avoid StringBuilder reallocations - int totalLength = values.Length - 1; // separators - foreach (string value in values) - totalLength += value?.Length ?? 0; - - var builder = new StringBuilder(totalLength); - - for (int i = 0; i < values.Length; i++) { - if (i > 0) builder.Append(separator); - if (values[i] is not null) - builder.Append(values[i].AsSpan()); - } - - return builder.ToString(); - } - - /// - /// Joins string arrays with string separator using span operations. - /// - /// Array of strings to join - /// Separator string - /// Joined string - public static string JoinOptimized(this string[] values, string separator) { - if (values.Length == 0) return string.Empty; - if (values.Length == 1) return values[0] ?? string.Empty; - - // Calculate total capacity - int separatorLength = separator?.Length ?? 0; - int totalLength = (values.Length - 1) * separatorLength; - foreach (string value in values) - totalLength += value?.Length ?? 0; - - var builder = new StringBuilder(totalLength); - - for (int i = 0; i < values.Length; i++) { - if (i > 0 && separator is not null) - builder.Append(separator.AsSpan()); - if (values[i] is not null) - builder.Append(values[i].AsSpan()); - } - - return builder.ToString(); - } - - /// - /// Splits strings using span operations with pre-allocated results array. - /// Provides better performance for known maximum split counts. - /// - /// Source string to split - /// Array of separator characters - /// String split options - /// Maximum expected number of splits for optimization - /// Array of split strings - public static string[] SplitOptimized(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) { - if (string.IsNullOrEmpty(source)) - return []; - - // Use span-based operations for better performance - ReadOnlySpan sourceSpan = source.AsSpan(); - var results = new List(Math.Min(maxSplits, 64)); // Cap initial capacity - - int start = 0; - for (int i = 0; i <= sourceSpan.Length; i++) { - bool isSeparator = i < sourceSpan.Length && separators.Contains(sourceSpan[i]); - bool isEnd = i == sourceSpan.Length; - - if (isSeparator || isEnd) { - ReadOnlySpan segment = sourceSpan[start..i]; - - if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) { - start = i + 1; - continue; - } - - if (options.HasFlag(StringSplitOptions.TrimEntries)) - segment = segment.Trim(); - - results.Add(segment.ToString()); - start = i + 1; - } - } - - return [.. results]; - } - - /// - /// Trims whitespace using span operations and returns the result as a string. - /// More efficient than traditional Trim() for subsequent string operations. - /// - /// Source string to trim - /// Trimmed string - public static string TrimOptimized(this string source) { - if (string.IsNullOrEmpty(source)) - return source ?? string.Empty; - - ReadOnlySpan trimmed = source.AsSpan().Trim(); - return trimmed.Length == source.Length ? source : trimmed.ToString(); - } - - /// - /// Efficiently checks if a string contains any of the specified characters using spans. - /// - /// Source string to search - /// Characters to search for - /// True if any character is found - public static bool ContainsAnyOptimized(this string source, ReadOnlySpan chars) - => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; - - /// - /// Replaces characters in a string using span operations for better performance. - /// - /// Source string - /// Character to replace - /// Replacement character - /// String with replacements - public static string ReplaceOptimized(this string source, char oldChar, char newChar) { - if (string.IsNullOrEmpty(source)) - return source ?? string.Empty; - - ReadOnlySpan sourceSpan = source.AsSpan(); - int firstIndex = sourceSpan.IndexOf(oldChar); - - if (firstIndex < 0) - return source; // No replacement needed - - // Use span-based building for efficiency - var result = new StringBuilder(source.Length); - int lastIndex = 0; - - do { - result.Append(sourceSpan[lastIndex..firstIndex]); - result.Append(newChar); - lastIndex = firstIndex + 1; - - if (lastIndex >= sourceSpan.Length) - break; - - firstIndex = sourceSpan[lastIndex..].IndexOf(oldChar); - if (firstIndex >= 0) - firstIndex += lastIndex; - - } while (firstIndex >= 0); - - if (lastIndex < sourceSpan.Length) - result.Append(sourceSpan[lastIndex..]); - - return result.ToString(); - } -} diff --git a/src/Utilities/StringExtensions.cs b/src/Utilities/StringExtensions.cs index 791a4ac..070a739 100644 --- a/src/Utilities/StringExtensions.cs +++ b/src/Utilities/StringExtensions.cs @@ -1,10 +1,13 @@ +using System.Text; + namespace PSTextMate.Utilities; /// /// Provides optimized string manipulation methods using modern .NET performance patterns. /// Uses Span and ReadOnlySpan to minimize memory allocations during text processing. /// -public static class StringExtensions { +public static class StringExtensions +{ /// /// Efficiently extracts substring using Span to avoid string allocations. /// This is significantly faster than traditional substring operations for large text processing. @@ -13,7 +16,8 @@ public static class StringExtensions { /// Starting index for substring /// Ending index for substring /// ReadOnlySpan representing the substring - public static ReadOnlySpan SubstringAsSpan(this string source, int startIndex, int endIndex) { + public static ReadOnlySpan SpanSubstring(this string source, int startIndex, int endIndex) + { return startIndex < 0 || endIndex > source.Length || startIndex > endIndex ? [] : source.AsSpan(startIndex, endIndex - startIndex); @@ -27,8 +31,9 @@ public static ReadOnlySpan SubstringAsSpan(this string source, int startIn /// Starting index for substring /// Ending index for substring /// Substring as string, or empty string if invalid indexes - public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) { - ReadOnlySpan span = source.SubstringAsSpan(startIndex, endIndex); + public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) + { + ReadOnlySpan span = source.SpanSubstring(startIndex, endIndex); return span.IsEmpty ? string.Empty : span.ToString(); } @@ -40,4 +45,145 @@ public static string SubstringAtIndexes(this string source, int startIndex, int /// True if all strings are null or empty, false otherwise public static bool AllIsNullOrEmpty(this string[] strings) => strings.All(string.IsNullOrEmpty); + + /// + /// Joins string arrays using span operations for better performance. + /// Avoids multiple string allocations during concatenation. + /// + /// Array of strings to join + /// Separator character + /// Joined string + public static string SpanJoin(this string[] values, char separator) + { + if (values.Length == 0) return string.Empty; + if (values.Length == 1) return values[0] ?? string.Empty; + + // Calculate total capacity to avoid StringBuilder reallocations + int totalLength = values.Length - 1; // separators + foreach (string value in values) + totalLength += value?.Length ?? 0; + + var builder = new StringBuilder(totalLength); + + for (int i = 0; i < values.Length; i++) + { + if (i > 0) builder.Append(separator); + if (values[i] is not null) + builder.Append(values[i].AsSpan()); + } + + return builder.ToString(); + } + + /// + /// Splits strings using span operations with pre-allocated results array. + /// Provides better performance for known maximum split counts. + /// + /// Source string to split + /// Array of separator characters + /// String split options + /// Maximum expected number of splits for optimization + /// Array of split strings + public static string[] SpanSplit(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) + { + if (string.IsNullOrEmpty(source)) + return []; + + // Use span-based operations for better performance + ReadOnlySpan sourceSpan = source.AsSpan(); + var results = new List(Math.Min(maxSplits, 64)); // Cap initial capacity + + int start = 0; + for (int i = 0; i <= sourceSpan.Length; i++) + { + bool isSeparator = i < sourceSpan.Length && separators.Contains(sourceSpan[i]); + bool isEnd = i == sourceSpan.Length; + + if (isSeparator || isEnd) + { + ReadOnlySpan segment = sourceSpan[start..i]; + + if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) + { + start = i + 1; + continue; + } + + if (options.HasFlag(StringSplitOptions.TrimEntries)) + segment = segment.Trim(); + + results.Add(segment.ToString()); + start = i + 1; + } + } + + return [.. results]; + } + + /// + /// Trims whitespace using span operations and returns the result as a string. + /// More efficient than traditional Trim() for subsequent string operations. + /// + /// Source string to trim + /// Trimmed string + public static string SpanTrim(this string source) + { + if (string.IsNullOrEmpty(source)) + return source ?? string.Empty; + + ReadOnlySpan trimmed = source.AsSpan().Trim(); + return trimmed.Length == source.Length ? source : trimmed.ToString(); + } + + /// + /// Efficiently checks if a string contains any of the specified characters using spans. + /// + /// Source string to search + /// Characters to search for + /// True if any character is found + public static bool SpanContainsAny(this string source, ReadOnlySpan chars) + => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; + + /// + /// Replaces characters in a string using span operations for better performance. + /// + /// Source string + /// Character to replace + /// Replacement character + /// String with replacements + public static string SpanReplace(this string source, char oldChar, char newChar) + { + if (string.IsNullOrEmpty(source)) + return source ?? string.Empty; + + ReadOnlySpan sourceSpan = source.AsSpan(); + int firstIndex = sourceSpan.IndexOf(oldChar); + + if (firstIndex < 0) + return source; // No replacement needed + + // Use span-based building for efficiency + var result = new StringBuilder(source.Length); + int lastIndex = 0; + + do + { + result.Append(sourceSpan[lastIndex..firstIndex]); + result.Append(newChar); + lastIndex = firstIndex + 1; + + if (lastIndex >= sourceSpan.Length) + break; + + firstIndex = sourceSpan[lastIndex..].IndexOf(oldChar); + if (firstIndex >= 0) + firstIndex += lastIndex; + + } while (firstIndex >= 0); + + if (lastIndex < sourceSpan.Length) + result.Append(sourceSpan[lastIndex..]); + + return result.ToString(); + } } diff --git a/src/Utilities/VTConversion.cs b/src/Utilities/VTConversion.cs index 6acb8a2..587a4df 100644 --- a/src/Utilities/VTConversion.cs +++ b/src/Utilities/VTConversion.cs @@ -2,7 +2,7 @@ using System.Text; using Spectre.Console; -namespace PSTextMate.Core.Helpers; +namespace PSTextMate.Helpers; /// /// Efficient parser for VT (Virtual Terminal) escape sequences that converts them to Spectre.Console objects. From 27297f0dfadda839c30402868127d6a1aa3aa910 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:53:40 +0100 Subject: [PATCH 14/25] - Added various markdown test files to validate rendering. - Implemented cmdlets for formatting C#, Markdown, and PowerShell with TextMate syntax highlighting. - Introduced cmdlets for retrieving supported TextMate languages and testing support for languages and extensions. - refactoredbuild script --- .editorconfig | 22 +- Module/PSTextMate.psd1 | 28 ++- .../Core/Markdown/MarkdownPipelinesTests.cs | 0 .../Core/Markdown/MarkdownRendererTests.cs | 0 .../Core/Markdown/TableRendererTests.cs | 0 .../Core/StandardRendererTests.cs | 0 .../Core/TextMateProcessorTests.cs | 0 .../SpectreTextMateStylerTests.cs | 0 .../Core/TokenProcessorTests.cs | 0 .../StringBuilderExtensionsTests.cs | 0 {tests => PSTextMate.Tests}/GlobalUsings.cs | 0 .../Infrastructure/CacheManagerTests.cs | 0 .../Integration/RenderingIntegrationTests.cs | 0 .../Integration/TaskListIntegrationTests.cs | 0 .../TaskListReflectionRemovalTests.cs | 0 .../PSTextMate.Tests.csproj | 0 {tests => PSTextMate.Tests}/demo-textmate.ps1 | 0 {tests => PSTextMate.Tests}/line-test-1.md | 0 {tests => PSTextMate.Tests}/line-test-2.md | 0 .../test-markdown-rendering.md | 0 {tests => PSTextMate.Tests}/test-markdown.md | 0 .../tests/Core/StandardRendererTests.cs | 0 .../tests/Core/TextMateProcessorTests.cs | 0 .../tests/Core/TokenProcessorTests.cs | 0 .../tests/Infrastructure/CacheManagerTests.cs | 0 PSTextMate.build.ps1 | 116 +++++++++++ PSTextMate.generated.sln | 25 --- PSTextMate.slnx | 3 + _build.ps1 | 42 ++++ build.ps1 | 74 +++---- rep.ps1 | 10 - src/Cmdlets/FormatCSharp.cs | 14 ++ src/Cmdlets/FormatMarkdown.cs | 22 ++ src/Cmdlets/FormatPowershell.cs | 14 ++ .../GetSupportedTextMate.cs | 0 .../ShowTextMateCmdlet.cs | 80 ++------ .../TestSupportedTextMate.cs | 0 src/Cmdlets/TextMateCmdletBase.cs | 190 ++++++++++++++++++ src/Core/TextMateProcessor.cs | 10 +- src/PSTextMate.csproj | 72 +++---- src/Rendering/CodeBlockRenderer.cs | 2 +- src/Rendering/HeadingRenderer.cs | 6 +- src/Rendering/HtmlBlockRenderer.cs | 2 +- src/Rendering/ListRenderer.cs | 2 +- src/Rendering/ParagraphRenderer.cs | 4 +- src/Rendering/QuoteRenderer.cs | 2 +- src/Rendering/TableRenderer.cs | 2 +- src/{ => Utilities}/AssemblyInfo.cs | 0 src/Utilities/Helpers.cs | 24 +++ src/Utilities/InlineTextExtractor.cs | 4 +- src/Utilities/StringExtensions.cs | 36 ++-- tests/Pester/ShowTextMate.Streaming.Tests.ps1 | 35 ---- 52 files changed, 577 insertions(+), 264 deletions(-) rename {tests => PSTextMate.Tests}/Core/Markdown/MarkdownPipelinesTests.cs (100%) rename {tests => PSTextMate.Tests}/Core/Markdown/MarkdownRendererTests.cs (100%) rename {tests => PSTextMate.Tests}/Core/Markdown/TableRendererTests.cs (100%) rename {tests => PSTextMate.Tests}/Core/StandardRendererTests.cs (100%) rename {tests => PSTextMate.Tests}/Core/TextMateProcessorTests.cs (100%) rename {tests => PSTextMate.Tests}/Core/TextMateStyling/SpectreTextMateStylerTests.cs (100%) rename {tests => PSTextMate.Tests}/Core/TokenProcessorTests.cs (100%) rename {tests => PSTextMate.Tests}/Extensions/StringBuilderExtensionsTests.cs (100%) rename {tests => PSTextMate.Tests}/GlobalUsings.cs (100%) rename {tests => PSTextMate.Tests}/Infrastructure/CacheManagerTests.cs (100%) rename {tests => PSTextMate.Tests}/Integration/RenderingIntegrationTests.cs (100%) rename {tests => PSTextMate.Tests}/Integration/TaskListIntegrationTests.cs (100%) rename {tests => PSTextMate.Tests}/Integration/TaskListReflectionRemovalTests.cs (100%) rename {tests => PSTextMate.Tests}/PSTextMate.Tests.csproj (100%) rename {tests => PSTextMate.Tests}/demo-textmate.ps1 (100%) rename {tests => PSTextMate.Tests}/line-test-1.md (100%) rename {tests => PSTextMate.Tests}/line-test-2.md (100%) rename {tests => PSTextMate.Tests}/test-markdown-rendering.md (100%) rename {tests => PSTextMate.Tests}/test-markdown.md (100%) rename {tests => PSTextMate.Tests}/tests/Core/StandardRendererTests.cs (100%) rename {tests => PSTextMate.Tests}/tests/Core/TextMateProcessorTests.cs (100%) rename {tests => PSTextMate.Tests}/tests/Core/TokenProcessorTests.cs (100%) rename {tests => PSTextMate.Tests}/tests/Infrastructure/CacheManagerTests.cs (100%) create mode 100644 PSTextMate.build.ps1 delete mode 100644 PSTextMate.generated.sln create mode 100644 PSTextMate.slnx create mode 100644 _build.ps1 delete mode 100644 rep.ps1 create mode 100644 src/Cmdlets/FormatCSharp.cs create mode 100644 src/Cmdlets/FormatMarkdown.cs create mode 100644 src/Cmdlets/FormatPowershell.cs rename src/{Commands => Cmdlets}/GetSupportedTextMate.cs (100%) rename src/{Commands => Cmdlets}/ShowTextMateCmdlet.cs (73%) rename src/{Commands => Cmdlets}/TestSupportedTextMate.cs (100%) create mode 100644 src/Cmdlets/TextMateCmdletBase.cs rename src/{ => Utilities}/AssemblyInfo.cs (100%) delete mode 100644 tests/Pester/ShowTextMate.Streaming.Tests.ps1 diff --git a/.editorconfig b/.editorconfig index f0a5377..048fa3f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,5 @@ root = true -# Consolidated .editorconfig for FileWatchRest -# - Modern C#/.NET 10 friendly defaults -# - Analyzer baseline: keep most suggestions non-breaking, escalate high-value performance/security rules to errors -# - Preserve relaxed analyzer severity for test projects - ############################################################ # Basic file-type formatting ############################################################ @@ -109,7 +104,7 @@ csharp_style_implicit_object_creation = true csharp_style_target_typed_new_expression = true csharp_style_pattern_matching_over_is_with_cast_check = true csharp_style_prefer_not_pattern = true -csharp_style_prefer_primary_constructors = false + csharp_style_pattern_matching_over_as_with_null_check = true csharp_style_inlined_variable_declaration = true csharp_style_throw_expression = true @@ -127,7 +122,12 @@ dotnet_diagnostic.IDE0030.severity = warning dotnet_diagnostic.IDE0270.severity = warning dotnet_diagnostic.IDE0019.severity = warning -# # Prefer var when the type is apparent (modern and concise) +# Prefer var when the type is apparent (modern and concise) +# how does this work with IDE0007? +# var and explicit typing preferences +# csharp_style_var_for_built_in_types = false:none +# csharp_style_var_when_type_is_apparent = true:suggestion +# csharp_style_var_elsewhere = false:suggestion csharp_style_var_when_type_is_apparent = true # # Expression-bodied members where concise @@ -152,9 +152,10 @@ dotnet_diagnostic.IDE0051.severity = suggestion # Unused private members dotnet_diagnostic.IDE0060.severity = suggestion # Unused parameters dotnet_diagnostic.IDE0058.severity = suggestion # Expression value is never used dotnet_diagnostic.IDE0130.severity = suggestion # Use 'new' expression where possible (target-typed new) - +csharp_style_unused_value_expression_statement_preference = unused_local_variable # Nullable reference types - enabled as suggestions; project opt-in controls runtime enforcement nullable = enable +csharp_style_prefer_primary_constructors = false # Formatting / newline preferences # prefer Stroustrup @@ -171,10 +172,7 @@ csharp_indent_case_contents_when_block = true csharp_indent_switch_labels = true csharp_indent_labels = one_less_than_current -# Var and explicit typing preferences -# csharp_style_var_for_built_in_types = false:none -# csharp_style_var_when_type_is_apparent = true:suggestion -# csharp_style_var_elsewhere = false:suggestion + # Using directive placement csharp_using_directive_placement = outside_namespace diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index 7b207f5..d8f59c7 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -1,15 +1,33 @@ @{ - RootModule = 'PSTextMate.dll' + RootModule = 'lib/PSTextMate.dll' ModuleVersion = '0.1.0' - GUID = '5ba21f1d-5ca4-49df-9a07-a2ad379feb00' + GUID = 'a6490f8a-1f53-44f2-899c-bf66b9c6e608' Author = 'trackd' CompanyName = 'trackd' Copyright = '(c) trackd. All rights reserved.' PowerShellVersion = '7.4' CompatiblePSEditions = 'Core' - CmdletsToExport = 'Show-TextMate', 'Test-SupportedTextMate', 'Get-SupportedTextMate' - AliasesToExport = '*' - RequiredAssemblies = './lib/TextMateSharp.dll', './lib/TextMateSharp.Grammars.dll', './lib/Onigwrap.dll', 'Markdig.Signed.dll' + CmdletsToExport = @( + 'Show-TextMate' + 'Test-SupportedTextMate' + 'Get-SupportedTextMate' + 'Format-CSharp' + 'Format-Markdown' + 'Format-PowerShell' + ) + AliasesToExport = @( + 'fcs' + 'fmd' + 'fps' + 'st' + 'Show-Code' + ) + RequiredAssemblies = @( + './lib/TextMateSharp.dll' + './lib/TextMateSharp.Grammars.dll' + './lib/Onigwrap.dll' + 'Markdig.Signed.dll' + ) FormatsToProcess = 'PSTextMate.format.ps1xml' RequiredModules = @( @{ diff --git a/tests/Core/Markdown/MarkdownPipelinesTests.cs b/PSTextMate.Tests/Core/Markdown/MarkdownPipelinesTests.cs similarity index 100% rename from tests/Core/Markdown/MarkdownPipelinesTests.cs rename to PSTextMate.Tests/Core/Markdown/MarkdownPipelinesTests.cs diff --git a/tests/Core/Markdown/MarkdownRendererTests.cs b/PSTextMate.Tests/Core/Markdown/MarkdownRendererTests.cs similarity index 100% rename from tests/Core/Markdown/MarkdownRendererTests.cs rename to PSTextMate.Tests/Core/Markdown/MarkdownRendererTests.cs diff --git a/tests/Core/Markdown/TableRendererTests.cs b/PSTextMate.Tests/Core/Markdown/TableRendererTests.cs similarity index 100% rename from tests/Core/Markdown/TableRendererTests.cs rename to PSTextMate.Tests/Core/Markdown/TableRendererTests.cs diff --git a/tests/Core/StandardRendererTests.cs b/PSTextMate.Tests/Core/StandardRendererTests.cs similarity index 100% rename from tests/Core/StandardRendererTests.cs rename to PSTextMate.Tests/Core/StandardRendererTests.cs diff --git a/tests/Core/TextMateProcessorTests.cs b/PSTextMate.Tests/Core/TextMateProcessorTests.cs similarity index 100% rename from tests/Core/TextMateProcessorTests.cs rename to PSTextMate.Tests/Core/TextMateProcessorTests.cs diff --git a/tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs b/PSTextMate.Tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs similarity index 100% rename from tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs rename to PSTextMate.Tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs diff --git a/tests/Core/TokenProcessorTests.cs b/PSTextMate.Tests/Core/TokenProcessorTests.cs similarity index 100% rename from tests/Core/TokenProcessorTests.cs rename to PSTextMate.Tests/Core/TokenProcessorTests.cs diff --git a/tests/Extensions/StringBuilderExtensionsTests.cs b/PSTextMate.Tests/Extensions/StringBuilderExtensionsTests.cs similarity index 100% rename from tests/Extensions/StringBuilderExtensionsTests.cs rename to PSTextMate.Tests/Extensions/StringBuilderExtensionsTests.cs diff --git a/tests/GlobalUsings.cs b/PSTextMate.Tests/GlobalUsings.cs similarity index 100% rename from tests/GlobalUsings.cs rename to PSTextMate.Tests/GlobalUsings.cs diff --git a/tests/Infrastructure/CacheManagerTests.cs b/PSTextMate.Tests/Infrastructure/CacheManagerTests.cs similarity index 100% rename from tests/Infrastructure/CacheManagerTests.cs rename to PSTextMate.Tests/Infrastructure/CacheManagerTests.cs diff --git a/tests/Integration/RenderingIntegrationTests.cs b/PSTextMate.Tests/Integration/RenderingIntegrationTests.cs similarity index 100% rename from tests/Integration/RenderingIntegrationTests.cs rename to PSTextMate.Tests/Integration/RenderingIntegrationTests.cs diff --git a/tests/Integration/TaskListIntegrationTests.cs b/PSTextMate.Tests/Integration/TaskListIntegrationTests.cs similarity index 100% rename from tests/Integration/TaskListIntegrationTests.cs rename to PSTextMate.Tests/Integration/TaskListIntegrationTests.cs diff --git a/tests/Integration/TaskListReflectionRemovalTests.cs b/PSTextMate.Tests/Integration/TaskListReflectionRemovalTests.cs similarity index 100% rename from tests/Integration/TaskListReflectionRemovalTests.cs rename to PSTextMate.Tests/Integration/TaskListReflectionRemovalTests.cs diff --git a/tests/PSTextMate.Tests.csproj b/PSTextMate.Tests/PSTextMate.Tests.csproj similarity index 100% rename from tests/PSTextMate.Tests.csproj rename to PSTextMate.Tests/PSTextMate.Tests.csproj diff --git a/tests/demo-textmate.ps1 b/PSTextMate.Tests/demo-textmate.ps1 similarity index 100% rename from tests/demo-textmate.ps1 rename to PSTextMate.Tests/demo-textmate.ps1 diff --git a/tests/line-test-1.md b/PSTextMate.Tests/line-test-1.md similarity index 100% rename from tests/line-test-1.md rename to PSTextMate.Tests/line-test-1.md diff --git a/tests/line-test-2.md b/PSTextMate.Tests/line-test-2.md similarity index 100% rename from tests/line-test-2.md rename to PSTextMate.Tests/line-test-2.md diff --git a/tests/test-markdown-rendering.md b/PSTextMate.Tests/test-markdown-rendering.md similarity index 100% rename from tests/test-markdown-rendering.md rename to PSTextMate.Tests/test-markdown-rendering.md diff --git a/tests/test-markdown.md b/PSTextMate.Tests/test-markdown.md similarity index 100% rename from tests/test-markdown.md rename to PSTextMate.Tests/test-markdown.md diff --git a/tests/tests/Core/StandardRendererTests.cs b/PSTextMate.Tests/tests/Core/StandardRendererTests.cs similarity index 100% rename from tests/tests/Core/StandardRendererTests.cs rename to PSTextMate.Tests/tests/Core/StandardRendererTests.cs diff --git a/tests/tests/Core/TextMateProcessorTests.cs b/PSTextMate.Tests/tests/Core/TextMateProcessorTests.cs similarity index 100% rename from tests/tests/Core/TextMateProcessorTests.cs rename to PSTextMate.Tests/tests/Core/TextMateProcessorTests.cs diff --git a/tests/tests/Core/TokenProcessorTests.cs b/PSTextMate.Tests/tests/Core/TokenProcessorTests.cs similarity index 100% rename from tests/tests/Core/TokenProcessorTests.cs rename to PSTextMate.Tests/tests/Core/TokenProcessorTests.cs diff --git a/tests/tests/Infrastructure/CacheManagerTests.cs b/PSTextMate.Tests/tests/Infrastructure/CacheManagerTests.cs similarity index 100% rename from tests/tests/Infrastructure/CacheManagerTests.cs rename to PSTextMate.Tests/tests/Infrastructure/CacheManagerTests.cs diff --git a/PSTextMate.build.ps1 b/PSTextMate.build.ps1 new file mode 100644 index 0000000..508311e --- /dev/null +++ b/PSTextMate.build.ps1 @@ -0,0 +1,116 @@ +#! /usr/bin/pwsh +#Requires -Version 7.4 -Module InvokeBuild +param( + [string]$Configuration = 'Release', + [switch]$SkipHelp, + [switch]$SkipTests +) +Write-Host "$($PSBoundParameters.GetEnumerator())" -ForegroundColor Cyan + +$modulename = [System.IO.Path]::GetFileName($PSCommandPath) -replace '\.build\.ps1$' + +$script:folders = @{ + ModuleName = $modulename + ProjectRoot = $PSScriptRoot + SourcePath = Join-Path $PSScriptRoot 'src' + OutputPath = Join-Path $PSScriptRoot 'output' + DestinationPath = Join-Path $PSScriptRoot 'output' 'lib' + ModuleSourcePath = Join-Path $PSScriptRoot 'module' + DocsPath = Join-Path $PSScriptRoot 'docs' 'en-US' + TestPath = Join-Path $PSScriptRoot 'tests' + CsprojPath = Join-Path $PSScriptRoot 'src' "$modulename.csproj" +} + +task Clean { + if (Test-Path $folders.OutputPath) { + Remove-Item -Path $folders.OutputPath -Recurse -Force -ErrorAction 'Ignore' + } + New-Item -Path $folders.OutputPath -ItemType Directory -Force | Out-Null +} + +task Build { + if (-not (Test-Path $folders.CsprojPath)) { + Write-Warning 'C# project not found, skipping Build' + return + } + try { + Push-Location $folders.SourcePath + $buildOutput = dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.DestinationPath 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Build failed:`n$buildOutput" + } + } + finally { + Pop-Location + } +} + +task ModuleFiles { + if (Test-Path $folders.ModuleSourcePath) { + Get-ChildItem -Path $folders.ModuleSourcePath -File | Copy-Item -Destination $folders.OutputPath -Force + } + else { + Write-Warning "Module directory not found at: $($folders.ModuleSourcePath)" + } +} + +task GenerateHelp -if (-not $SkipHelp) { + if (-not (Test-Path $folders.DocsPath)) { + Write-Warning "Documentation path not found at: $($folders.DocsPath)" + return + } + if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PlatyPS)) { + Write-Host ' Installing Microsoft.PowerShell.PlatyPS...' -ForegroundColor Yellow + Install-Module -Name Microsoft.PowerShell.PlatyPS -Scope CurrentUser -Force -AllowClobber + } + + Import-Module Microsoft.PowerShell.PlatyPS -ErrorAction Stop + + $modulePath = Join-Path $folders.OutputPath ($folders.ModuleName + '.psd1') + if (-not (Test-Path $modulePath)) { + Write-Warning "Module manifest not found at: $modulePath. Skipping help generation." + return + } + + Import-Module $modulePath -Force + + $helpOutputPath = Join-Path $folders.OutputPath 'en-US' + New-Item -Path $helpOutputPath -ItemType Directory -Force | Out-Null + + $allCommandHelp = Get-ChildItem -Path $folders.DocsPath -Filter '*.md' -Recurse -File | + Where-Object { $_.Name -ne "$($folders.ModuleName).md" } | + Import-MarkdownCommandHelp + + if ($allCommandHelp.Count -gt 0) { + $tempOutputPath = Join-Path $helpOutputPath 'temp' + Export-MamlCommandHelp -CommandHelp $allCommandHelp -OutputFolder $tempOutputPath -Force | Out-Null + + $generatedFile = Get-ChildItem -Path $tempOutputPath -Filter '*.xml' -Recurse -File | Select-Object -First 1 + if ($generatedFile) { + Move-Item -Path $generatedFile.FullName -Destination $helpOutputPath -Force + } + Remove-Item -Path $tempOutputPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +task Test -if (-not $SkipTests) { + if (-not (Test-Path $folders.TestPath)) { + Write-Warning "Test directory not found at: $($folders.TestPath)" + return + } + $pesterConfig = New-PesterConfiguration + # $pesterConfig.Output.Verbosity = 'Detailed' + $pesterConfig.Run.Path = $folders.TestPath + $pesterConfig.Run.Throw = $true + $pesterConfig.Debug.WriteDebugMessages = $false + Invoke-Pester -Configuration $pesterConfig +} +task CleanAfter { + if ($script:config.DestinationPath -and (Test-Path $script:config.DestinationPath)) { + Get-ChildItem $script:config.DestinationPath -File | Where-Object { $_.Extension -in '.pdb', '.json' } | Remove-Item -Force -ErrorAction Ignore + } +} + + +task All -Jobs Clean, Build, ModuleFiles, GenerateHelp, CleanAfter #, Test +task BuildAndTest -Jobs Clean, Build, ModuleFiles, CleanAfter #, Test diff --git a/PSTextMate.generated.sln b/PSTextMate.generated.sln deleted file mode 100644 index 6e7fe2e..0000000 --- a/PSTextMate.generated.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PSTextMate", "src\PSTextMate.csproj", "{F9DA8F41-8002-4A61-A0BD-7460B90950F7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E06DE8A7-9359-41CD-923E-6201952ECDFD} - EndGlobalSection -EndGlobal diff --git a/PSTextMate.slnx b/PSTextMate.slnx new file mode 100644 index 0000000..ef6d148 --- /dev/null +++ b/PSTextMate.slnx @@ -0,0 +1,3 @@ + + + diff --git a/_build.ps1 b/_build.ps1 new file mode 100644 index 0000000..332bd7c --- /dev/null +++ b/_build.ps1 @@ -0,0 +1,42 @@ +#Requires -Version 7.4 +if (-Not $PSScriptRoot) { + return 'Run this script from the root of the project' +} +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot + +dotnet clean +dotnet restore + +$ModuleFilesFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Module' +if (-Not (Test-Path $ModuleFilesFolder)) { + $null = New-Item -ItemType Directory -Path $ModuleFilesFolder -Force +} +Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -File -Recurse | Remove-Item -Force + +$moduleLibFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Output' | Join-Path -ChildPath 'lib' +if (-Not (Test-Path $moduleLibFolder)) { + $null = New-Item -ItemType Directory -Path $moduleLibFolder -Force +} + +$csproj = Get-Item (Join-Path -Path $PSScriptRoot -ChildPath 'src' | Join-Path -ChildPath 'PSTextMate.csproj') +$outputfolder = Join-Path -Path $PSScriptRoot -ChildPath 'packages' +if (-Not (Test-Path -Path $outputfolder)) { + $null = New-Item -ItemType Directory -Path $outputfolder -Force +} + +dotnet publish $csproj.FullName -c Release -o $outputfolder +Copy-Item -Path $ModuleFilesFolder/* -Destination (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -Force -Recurse -Include '*.psd1', '*.psm1', '*.ps1xml' + +Get-ChildItem -Path $moduleLibFolder -File | Remove-Item -Force + + +Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'win-x64' | Join-Path -ChildPath 'native') -Filter *.dll | Move-Item -Destination $moduleLibFolder -Force +Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'osx-arm64' | Join-Path -ChildPath 'native') -Filter *.dylib | Move-Item -Destination $moduleLibFolder -Force +Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'linux-x64' | Join-Path -ChildPath 'native') -Filter *.so | Copy-Item -Destination $moduleLibFolder -Force +Move-Item (Join-Path -Path $outputfolder -ChildPath 'PSTextMate.dll') -Destination (Split-Path $moduleLibFolder) -Force +Get-ChildItem -Path $outputfolder -File | + Where-Object { -Not $_.Name.StartsWith('System.Text') -And $_.Extension -notin '.json','.pdb','.xml' } | + Move-Item -Destination $moduleLibFolder -Force + +Pop-Location diff --git a/build.ps1 b/build.ps1 index 017406e..d887721 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,42 +1,44 @@ -#Requires -Version 7.4 -if (-Not $PSScriptRoot) { - return 'Run this script from the root of the project' -} -$ErrorActionPreference = 'Stop' -Push-Location $PSScriptRoot - -dotnet clean -dotnet restore +#! /usr/bin/pwsh +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + [switch]$SkipHelp, + [switch]$SkipTests, + [Switch]$BuildAndTestOnly, + [string] $Task +) -$ModuleFilesFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Module' -if (-Not (Test-Path $ModuleFilesFolder)) { - $null = New-Item -ItemType Directory -Path $ModuleFilesFolder -Force +$ErrorActionPreference = 'Stop' +# Helper function to get paths +$buildparams = @{ + Configuration = $Configuration + SkipHelp = $SkipHelp.IsPresent + SkipTests = $SkipTests.IsPresent + File = Join-Path $PSScriptRoot 'PSTextMate.build.ps1' + Task = 'All' + Result = 'Result' + Safe = $true } -Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -File -Recurse | Remove-Item -Force - -$moduleLibFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Output' | Join-Path -ChildPath 'lib' -if (-Not (Test-Path $moduleLibFolder)) { - $null = New-Item -ItemType Directory -Path $moduleLibFolder -Force +if (-not (Get-Module -ListAvailable -Name InvokeBuild)) { + Install-Module -Name InvokeBuild -Scope CurrentUser -Force -AllowClobber } +Import-Module InvokeBuild -ErrorAction Stop -$csproj = Get-Item (Join-Path -Path $PSScriptRoot -ChildPath 'src' | Join-Path -ChildPath 'PSTextMate.csproj') -$outputfolder = Join-Path -Path $PSScriptRoot -ChildPath 'packages' -if (-Not (Test-Path -Path $outputfolder)) { - $null = New-Item -ItemType Directory -Path $outputfolder -Force +if ($task) { + $buildparams.Task = $task +} +elseif ($BuildAndTestOnly) { + $buildparams.Task = 'BuildAndTest' } -dotnet publish $csproj.FullName -c Release -o $outputfolder -Copy-Item -Path $ModuleFilesFolder/* -Destination (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -Force -Recurse -Include '*.psd1', '*.psm1', '*.ps1xml' - -Get-ChildItem -Path $moduleLibFolder -File | Remove-Item -Force - - -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'win-x64' | Join-Path -ChildPath 'native') -Filter *.dll | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'osx' | Join-Path -ChildPath 'native') -Filter *.dylib | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'linux-x64' | Join-Path -ChildPath 'native') -Filter *.so | Copy-Item -Destination $moduleLibFolder -Force -Move-Item (Join-Path -Path $outputfolder -ChildPath 'PSTextMate.dll') -Destination (Split-Path $moduleLibFolder) -Force -Get-ChildItem -Path $outputfolder -File | - Where-Object { -Not $_.Name.StartsWith('System.Text') -And $_.Extension -notin '.json','.pdb','.xml' } | - Move-Item -Destination $moduleLibFolder -Force - -Pop-Location +if (-not $env:CI) { + # this is just so the dll doesn't get locked on and i can rebuild without restarting terminal + $sb = { + param($bp) + Invoke-Build @bp + } + pwsh -NoProfile -Command $sb -args $buildparams +} +else { + Invoke-Build @buildparams +} diff --git a/rep.ps1 b/rep.ps1 deleted file mode 100644 index 509588c..0000000 --- a/rep.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -Push-Location $PSScriptRoot -$f = & .\build.ps1 -Import-Module PwshSpectreConsole -Import-Module ./output/PSTextMate.psd1 -# $c = Get-Content ./tests/test-markdown.md -Raw -# $c | Show-TextMate -Verbose -# Get-Item ./tests/test-markdown.md | Show-TextMate -Verbose -Show-TextMate -Path ./tests/test-markdown.md #-Verbose -Show-TextMate -Path ./tests/test-markdown.md -Alternate #-Verbose -Pop-Location diff --git a/src/Cmdlets/FormatCSharp.cs b/src/Cmdlets/FormatCSharp.cs new file mode 100644 index 0000000..a950cc7 --- /dev/null +++ b/src/Cmdlets/FormatCSharp.cs @@ -0,0 +1,14 @@ +using System.Management.Automation; +using PSTextMate.Core; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for rendering C# input using TextMate syntax highlighting. +/// +[Cmdlet(VerbsCommon.Format, "CSharp")] +[Alias("fcs")] +[OutputType(typeof(HighlightedText))] +public sealed class FormatCSharpCmdlet : TextMateCmdletBase { + protected override string FixedToken => "csharp"; +} diff --git a/src/Cmdlets/FormatMarkdown.cs b/src/Cmdlets/FormatMarkdown.cs new file mode 100644 index 0000000..38fa96e --- /dev/null +++ b/src/Cmdlets/FormatMarkdown.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using PSTextMate.Core; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for rendering Markdown input using TextMate syntax highlighting. +/// +[Cmdlet(VerbsCommon.Format, "Markdown")] +[Alias("fmd")] +[OutputType(typeof(HighlightedText))] +public sealed class FormatMarkdownCmdlet : TextMateCmdletBase { + /// + /// When present, force the standard renderer even for Markdown grammars. + /// + [Parameter] + public SwitchParameter Alternate { get; set; } + + protected override string FixedToken => "markdown"; + protected override bool UsesMarkdownBaseDirectory => true; + protected override bool UseAlternate => Alternate.IsPresent; +} diff --git a/src/Cmdlets/FormatPowershell.cs b/src/Cmdlets/FormatPowershell.cs new file mode 100644 index 0000000..bcaa7c1 --- /dev/null +++ b/src/Cmdlets/FormatPowershell.cs @@ -0,0 +1,14 @@ +using System.Management.Automation; +using PSTextMate.Core; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for rendering PowerShell input using TextMate syntax highlighting. +/// +[Cmdlet(VerbsCommon.Format, "PowerShell")] +[Alias("fps")] +[OutputType(typeof(HighlightedText))] +public sealed class FormatPowerShellCmdlet : TextMateCmdletBase { + protected override string FixedToken => "powershell"; +} diff --git a/src/Commands/GetSupportedTextMate.cs b/src/Cmdlets/GetSupportedTextMate.cs similarity index 100% rename from src/Commands/GetSupportedTextMate.cs rename to src/Cmdlets/GetSupportedTextMate.cs diff --git a/src/Commands/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs similarity index 73% rename from src/Commands/ShowTextMateCmdlet.cs rename to src/Cmdlets/ShowTextMateCmdlet.cs index 500f7ec..bcf5122 100644 --- a/src/Commands/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -47,12 +47,6 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter] public ThemeName Theme { get; set; } = ThemeName.DarkPlus; - /// - /// Enables streaming mode for large files, processing in batches. - /// - [Parameter] - public SwitchParameter Stream { get; set; } - /// /// When present, force use of the standard renderer even for Markdown grammars. /// This can be used to preview alternate rendering behavior. @@ -60,15 +54,6 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter] public SwitchParameter Alternate { get; set; } - /// - /// Number of lines to process per batch when streaming (default: 1000). - /// - [Parameter] - public int BatchSize { get; set; } = 1000; - - /// - /// Processes each input record from the pipeline. - /// protected override void ProcessRecord() { if (MyInvocation.ExpectingInput) { if (InputObject?.BaseObject is FileInfo file) { @@ -131,7 +116,7 @@ protected override void EndProcessing() { private HighlightedText? ProcessStringInput() { // Normalize buffered strings into lines - string[] lines = NormalizeToLines(_inputObjectBuffer); + string[] lines = TextMateHelper.NormalizeToLines(_inputObjectBuffer); if (lines.AllIsNullOrEmpty()) { WriteVerbose("All input strings are null or empty"); @@ -175,64 +160,28 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { ? TextMateResolver.ResolveToken(Language) : (filePath.Extension, true); - if (Stream.IsPresent) { - // Streaming mode - yield HighlightedText objects directly from processor - WriteVerbose($"Streaming file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}, batch size: {BatchSize}"); - - // Direct passthrough - processor returns HighlightedText now - foreach (HighlightedText result in TextMateProcessor.ProcessFileInBatches(filePath.FullName, BatchSize, Theme, token, asExtension, Alternate.IsPresent)) { - yield return result; - } - } - else { - // Single file processing - WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); + // Single file processing + WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); - string[] lines = File.ReadAllLines(filePath.FullName); - IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: Alternate.IsPresent); + string[] lines = File.ReadAllLines(filePath.FullName); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: Alternate.IsPresent); - if (renderables is not null) { - yield return new HighlightedText { - Renderables = renderables - }; - } + if (renderables is not null) { + yield return new HighlightedText { + Renderables = renderables + }; } } - private static string[] NormalizeToLines(List buffer) { - - if (buffer.Count == 0) { - return []; - } - - // Multiple strings in buffer - treat each as a line - if (buffer.Count > 1) { - return [.. buffer]; - } - - // Single string - check if it contains newlines - string? single = buffer[0]; - if (string.IsNullOrEmpty(single)) { - return single is not null ? [single] : []; - } - - // Split on newlines if present - if (single.Contains('\n') || single.Contains('\r')) { - return single.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); - } - - // Single string with no newlines - return [single]; - } private void GetSourceHint() { if (InputObject is null) return; - string? hint = InputObject.Properties["PSPath"]?.Value as string - ?? InputObject.Properties["FullName"]?.Value as string; + ?? InputObject.Properties["FullName"]?.Value as string + ?? InputObject.Properties["PSChildName"]?.Value as string; if (string.IsNullOrEmpty(hint)) return; - // remove potential Provider stuff from string. - hint = GetUnresolvedProviderPathFromPSPath(hint); + WriteVerbose($"Language Hint: {hint}"); + if (_sourceExtensionHint is null) { string ext = Path.GetExtension(hint); if (!string.IsNullOrWhiteSpace(ext)) { @@ -240,7 +189,8 @@ private void GetSourceHint() { WriteVerbose($"Detected extension hint from PSPath: {ext}"); } } - + // remove potential Provider stuff from string. + hint = GetUnresolvedProviderPathFromPSPath(hint); if (_sourceBaseDirectory is null) { string? baseDir = Path.GetDirectoryName(hint); if (!string.IsNullOrWhiteSpace(baseDir)) { diff --git a/src/Commands/TestSupportedTextMate.cs b/src/Cmdlets/TestSupportedTextMate.cs similarity index 100% rename from src/Commands/TestSupportedTextMate.cs rename to src/Cmdlets/TestSupportedTextMate.cs diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs new file mode 100644 index 0000000..43ef768 --- /dev/null +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -0,0 +1,190 @@ +using System.Management.Automation; +using PSTextMate; +using PSTextMate.Core; +using PSTextMate.Utilities; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; + +namespace PSTextMate.Commands; + +/// +/// Base cmdlet for rendering input using a fixed TextMate language or extension token. +/// +public abstract class TextMateCmdletBase : PSCmdlet { + private readonly List _inputObjectBuffer = []; + private string? _sourceBaseDirectory; + + /// + /// String content or file path to render with syntax highlighting. + /// + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, + Position = 0 + )] + [AllowEmptyString] + [AllowNull] + [Alias("FullName", "Path")] + public PSObject? InputObject { get; set; } + + /// + /// Color theme to use for syntax highlighting. + /// + [Parameter] + public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + + /// + /// Fixed language or extension token used for rendering. + /// + protected abstract string FixedToken { get; } + + /// + /// Indicates whether the fixed token should be treated as a file extension. + /// + protected virtual bool FixedTokenIsExtension => false; + + /// + /// Indicates whether a Markdown base directory should be resolved for image paths. + /// + protected virtual bool UsesMarkdownBaseDirectory => false; + + /// + /// Error identifier used for error records. + /// + protected virtual string ErrorId => GetType().Name; + + /// + /// Indicates whether alternate rendering should be used. + /// + protected virtual bool UseAlternate => false; + + protected override void ProcessRecord() { + if (MyInvocation.ExpectingInput) { + if (InputObject?.BaseObject is FileInfo file) { + try { + foreach (HighlightedText result in ProcessPathInput(file)) { + WriteObject(result.Renderables, enumerateCollection: true); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, ErrorId, ErrorCategory.NotSpecified, file)); + } + } + else if (InputObject?.BaseObject is string inputString) { + if (UsesMarkdownBaseDirectory) { + EnsureBaseDirectoryFromInput(); + } + + _inputObjectBuffer.Add(inputString); + } + } + else if (InputObject is not null) { + FileInfo file = new(GetUnresolvedProviderPathFromPSPath(InputObject?.ToString())); + if (!file.Exists) { + return; + } + + try { + foreach (HighlightedText result in ProcessPathInput(file)) { + WriteObject(result.Renderables, enumerateCollection: true); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, ErrorId, ErrorCategory.NotSpecified, file)); + } + } + } + + protected override void EndProcessing() { + try { + if (_inputObjectBuffer.Count == 0) { + return; + } + + if (UsesMarkdownBaseDirectory) { + EnsureBaseDirectoryFromInput(); + } + + HighlightedText? result = ProcessStringInput(); + if (result is not null) { + WriteObject(result.Renderables, enumerateCollection: true); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, ErrorId, ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); + } + } + + private HighlightedText? ProcessStringInput() { + string[] lines = TextMateHelper.NormalizeToLines(_inputObjectBuffer); + + if (lines.AllIsNullOrEmpty()) { + WriteVerbose("All input strings are null or empty"); + return null; + } + + (string token, bool asExtension) = ResolveFixedToken(); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: UseAlternate); + + return renderables is null + ? null + : new HighlightedText { + Renderables = renderables + }; + } + + private IEnumerable ProcessPathInput(FileInfo filePath) { + if (!filePath.Exists) { + throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); + } + + if (UsesMarkdownBaseDirectory) { + string markdownBaseDir = filePath.DirectoryName ?? Environment.CurrentDirectory; + Rendering.ImageRenderer.CurrentMarkdownDirectory = markdownBaseDir; + WriteVerbose($"Set markdown base directory for image resolution: {markdownBaseDir}"); + } + + (string token, bool asExtension) = ResolveFixedToken(); + WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); + + string[] lines = File.ReadAllLines(filePath.FullName); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: UseAlternate); + + if (renderables is not null) { + yield return new HighlightedText { + Renderables = renderables + }; + } + } + + private (string token, bool asExtension) ResolveFixedToken() { + if (!FixedTokenIsExtension) { + return TextMateResolver.ResolveToken(FixedToken); + } + + string token = FixedToken.StartsWith('.') ? FixedToken : "." + FixedToken; + return (token, true); + } + + private void EnsureBaseDirectoryFromInput() { + if (_sourceBaseDirectory is not null || InputObject is null) { + return; + } + + string? hint = InputObject.Properties["PSPath"]?.Value as string + ?? InputObject.Properties["FullName"]?.Value as string + ?? InputObject.Properties["PSChildName"]?.Value as string; + if (string.IsNullOrEmpty(hint)) { + return; + } + + string resolvedPath = GetUnresolvedProviderPathFromPSPath(hint); + string? baseDir = Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrWhiteSpace(baseDir)) { + _sourceBaseDirectory = baseDir; + Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; + WriteVerbose($"Set markdown base directory from input: {baseDir}"); + } + } +} diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index fcfceba..1671d28 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -1,6 +1,6 @@ using System.Text; -using PSTextMate.Utilities; using PSTextMate.Core; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; @@ -37,11 +37,9 @@ public static class TextMateProcessor { IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); // if alternate it will use TextMate for markdown as well. - if (grammar.GetName() == "Markdown" && forceAlternate) { - return StandardRenderer.Render(lines, theme, grammar); - } - - return (grammar.GetName() == "Markdown") + return grammar.GetName() == "Markdown" && forceAlternate + ? StandardRenderer.Render(lines, theme, grammar) + : (grammar.GetName() == "Markdown") ? MarkdownRenderer.Render(lines, theme, grammar, themeName) : StandardRenderer.Render(lines, theme, grammar); } diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index 889740a..e1bc255 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -1,36 +1,40 @@ - - PSTextMate - net8.0 - enable - 0.1.0 - enable - 13.0 - Recommended - true - true - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - + + PSTextMate + net8.0 + enable + 0.1.0 + enable + 13.0 + true + win-x64;linux-x64;osx-arm64 + + + + true + true + latest-Recommended + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/src/Rendering/CodeBlockRenderer.cs b/src/Rendering/CodeBlockRenderer.cs index 780e4c9..fc25a3c 100644 --- a/src/Rendering/CodeBlockRenderer.cs +++ b/src/Rendering/CodeBlockRenderer.cs @@ -2,12 +2,12 @@ using System.Text; using Markdig.Helpers; using Markdig.Syntax; +using PSTextMate.Core; using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using PSTextMate.Core; namespace PSTextMate.Rendering; diff --git a/src/Rendering/HeadingRenderer.cs b/src/Rendering/HeadingRenderer.cs index b4dc67c..56e2f69 100644 --- a/src/Rendering/HeadingRenderer.cs +++ b/src/Rendering/HeadingRenderer.cs @@ -1,11 +1,11 @@ +using System.Text; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using PSTextMate.Core; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; -using System.Text; using TextMateSharp.Themes; -using PSTextMate.Utilities; -using PSTextMate.Core; namespace PSTextMate.Rendering; diff --git a/src/Rendering/HtmlBlockRenderer.cs b/src/Rendering/HtmlBlockRenderer.cs index f8d156c..8273e87 100644 --- a/src/Rendering/HtmlBlockRenderer.cs +++ b/src/Rendering/HtmlBlockRenderer.cs @@ -1,9 +1,9 @@ using Markdig.Syntax; +using PSTextMate.Core; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using PSTextMate.Core; namespace PSTextMate.Rendering; diff --git a/src/Rendering/ListRenderer.cs b/src/Rendering/ListRenderer.cs index dfb8745..98a6d9c 100644 --- a/src/Rendering/ListRenderer.cs +++ b/src/Rendering/ListRenderer.cs @@ -2,10 +2,10 @@ using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PSTextMate.Utilities; namespace PSTextMate.Rendering; diff --git a/src/Rendering/ParagraphRenderer.cs b/src/Rendering/ParagraphRenderer.cs index eb77a32..d1d7fd7 100644 --- a/src/Rendering/ParagraphRenderer.cs +++ b/src/Rendering/ParagraphRenderer.cs @@ -5,11 +5,11 @@ using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using PSTextMate.Core; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PSTextMate.Utilities; -using PSTextMate.Core; namespace PSTextMate.Rendering; diff --git a/src/Rendering/QuoteRenderer.cs b/src/Rendering/QuoteRenderer.cs index 25c0257..4a5d8b2 100644 --- a/src/Rendering/QuoteRenderer.cs +++ b/src/Rendering/QuoteRenderer.cs @@ -1,9 +1,9 @@ using System.Text; using Markdig.Syntax; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PSTextMate.Utilities; namespace PSTextMate.Rendering; diff --git a/src/Rendering/TableRenderer.cs b/src/Rendering/TableRenderer.cs index 9d5c170..96475ae 100644 --- a/src/Rendering/TableRenderer.cs +++ b/src/Rendering/TableRenderer.cs @@ -3,11 +3,11 @@ using Markdig.Helpers; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using PSTextMate.Core; using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; -using PSTextMate.Core; namespace PSTextMate.Rendering; diff --git a/src/AssemblyInfo.cs b/src/Utilities/AssemblyInfo.cs similarity index 100% rename from src/AssemblyInfo.cs rename to src/Utilities/AssemblyInfo.cs diff --git a/src/Utilities/Helpers.cs b/src/Utilities/Helpers.cs index 07819a5..ac364e5 100644 --- a/src/Utilities/Helpers.cs +++ b/src/Utilities/Helpers.cs @@ -36,4 +36,28 @@ static TextMateHelper() { throw new TypeInitializationException(nameof(TextMateHelper), ex); } } + internal static string[] NormalizeToLines(List buffer) { + if (buffer.Count == 0) { + return []; + } + + // Multiple strings in buffer - treat each as a line + if (buffer.Count > 1) { + return [.. buffer]; + } + + // Single string - check if it contains newlines + string? single = buffer[0]; + if (string.IsNullOrEmpty(single)) { + return single is not null ? [single] : []; + } + + // Split on newlines if present + if (single.Contains('\n') || single.Contains('\r')) { + return single.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + } + + // Single string with no newlines + return [single]; + } } diff --git a/src/Utilities/InlineTextExtractor.cs b/src/Utilities/InlineTextExtractor.cs index cbc33e2..5a682c7 100644 --- a/src/Utilities/InlineTextExtractor.cs +++ b/src/Utilities/InlineTextExtractor.cs @@ -1,5 +1,5 @@ -using Markdig.Syntax.Inlines; -using System.Text; +using System.Text; +using Markdig.Syntax.Inlines; namespace PSTextMate.Utilities; diff --git a/src/Utilities/StringExtensions.cs b/src/Utilities/StringExtensions.cs index 070a739..89c200d 100644 --- a/src/Utilities/StringExtensions.cs +++ b/src/Utilities/StringExtensions.cs @@ -6,8 +6,7 @@ namespace PSTextMate.Utilities; /// Provides optimized string manipulation methods using modern .NET performance patterns. /// Uses Span and ReadOnlySpan to minimize memory allocations during text processing. /// -public static class StringExtensions -{ +public static class StringExtensions { /// /// Efficiently extracts substring using Span to avoid string allocations. /// This is significantly faster than traditional substring operations for large text processing. @@ -16,8 +15,7 @@ public static class StringExtensions /// Starting index for substring /// Ending index for substring /// ReadOnlySpan representing the substring - public static ReadOnlySpan SpanSubstring(this string source, int startIndex, int endIndex) - { + public static ReadOnlySpan SpanSubstring(this string source, int startIndex, int endIndex) { return startIndex < 0 || endIndex > source.Length || startIndex > endIndex ? [] : source.AsSpan(startIndex, endIndex - startIndex); @@ -31,8 +29,7 @@ public static ReadOnlySpan SpanSubstring(this string source, int startInde /// Starting index for substring /// Ending index for substring /// Substring as string, or empty string if invalid indexes - public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) - { + public static string SubstringAtIndexes(this string source, int startIndex, int endIndex) { ReadOnlySpan span = source.SpanSubstring(startIndex, endIndex); return span.IsEmpty ? string.Empty : span.ToString(); } @@ -53,8 +50,7 @@ public static bool AllIsNullOrEmpty(this string[] strings) /// Array of strings to join /// Separator character /// Joined string - public static string SpanJoin(this string[] values, char separator) - { + public static string SpanJoin(this string[] values, char separator) { if (values.Length == 0) return string.Empty; if (values.Length == 1) return values[0] ?? string.Empty; @@ -65,8 +61,7 @@ public static string SpanJoin(this string[] values, char separator) var builder = new StringBuilder(totalLength); - for (int i = 0; i < values.Length; i++) - { + for (int i = 0; i < values.Length; i++) { if (i > 0) builder.Append(separator); if (values[i] is not null) builder.Append(values[i].AsSpan()); @@ -84,8 +79,7 @@ public static string SpanJoin(this string[] values, char separator) /// String split options /// Maximum expected number of splits for optimization /// Array of split strings - public static string[] SpanSplit(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) - { + public static string[] SpanSplit(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) { if (string.IsNullOrEmpty(source)) return []; @@ -94,17 +88,14 @@ public static string[] SpanSplit(this string source, char[] separators, StringSp var results = new List(Math.Min(maxSplits, 64)); // Cap initial capacity int start = 0; - for (int i = 0; i <= sourceSpan.Length; i++) - { + for (int i = 0; i <= sourceSpan.Length; i++) { bool isSeparator = i < sourceSpan.Length && separators.Contains(sourceSpan[i]); bool isEnd = i == sourceSpan.Length; - if (isSeparator || isEnd) - { + if (isSeparator || isEnd) { ReadOnlySpan segment = sourceSpan[start..i]; - if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) - { + if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) { start = i + 1; continue; } @@ -126,8 +117,7 @@ public static string[] SpanSplit(this string source, char[] separators, StringSp /// /// Source string to trim /// Trimmed string - public static string SpanTrim(this string source) - { + public static string SpanTrim(this string source) { if (string.IsNullOrEmpty(source)) return source ?? string.Empty; @@ -151,8 +141,7 @@ public static bool SpanContainsAny(this string source, ReadOnlySpan chars) /// Character to replace /// Replacement character /// String with replacements - public static string SpanReplace(this string source, char oldChar, char newChar) - { + public static string SpanReplace(this string source, char oldChar, char newChar) { if (string.IsNullOrEmpty(source)) return source ?? string.Empty; @@ -166,8 +155,7 @@ public static string SpanReplace(this string source, char oldChar, char newChar) var result = new StringBuilder(source.Length); int lastIndex = 0; - do - { + do { result.Append(sourceSpan[lastIndex..firstIndex]); result.Append(newChar); lastIndex = firstIndex + 1; diff --git a/tests/Pester/ShowTextMate.Streaming.Tests.ps1 b/tests/Pester/ShowTextMate.Streaming.Tests.ps1 deleted file mode 100644 index 72606d5..0000000 --- a/tests/Pester/ShowTextMate.Streaming.Tests.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -Describe 'Show-TextMate -Stream' { - It 'emits multiple RenderableBatch objects with correct coverage' { - # Arrange - $lines = 0..2499 | ForEach-Object { if ($_ % 5 -eq 0) { '// comment line' } else { 'var x = 1; // code' } } - $temp = [System.IO.Path]::GetTempFileName() - Set-Content -Path $temp -Value $lines -NoNewline - - try { - # The build pipeline (build.ps1) should have published the module into the Output folder - $moduleManifest = Join-Path -Path (Resolve-Path "$PSScriptRoot\..\..\Output") -ChildPath 'PSTextMate.psd1' - if (-Not (Test-Path $moduleManifest)) { - throw "Module manifest not found at $moduleManifest. Ensure build.ps1 was run prior to tests." - } - - Import-Module -Name $moduleManifest -Force -ErrorAction Stop - - # Act - $batches = Show-TextMate -Path $temp -Stream -BatchSize 1000 | Where-Object { $_ -is [PwshSpectreConsole.TextMate.Core.RenderableBatch] } | Select-Object -Property BatchIndex, LineCount - - # Assert - $batches | Should -Not -BeNullOrEmpty - $batches.Count | Should -BeGreaterThan 1 - - $covered = ($batches | Measure-Object -Property LineCount -Sum).Sum - $covered | Should -BeGreaterOrEqualTo $lines.Count - - # Ensure batch indexes are sequential starting from zero - $expected = 0..($batches.Count - 1) - ($batches | ForEach-Object { $_.BatchIndex }) | Should -Be $expected - } - finally { - Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue - } - } -} From 0901add76a1e368fc9942d261cba6f41ab018eab Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:53:06 +0100 Subject: [PATCH 15/25] =?UTF-8?q?feat(tests):=20=E2=9C=A8=20Add=20comprehe?= =?UTF-8?q?nsive=20tests=20for=20formatting=20C#,=20PowerShell,=20and=20Ma?= =?UTF-8?q?rkdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced tests for `Format-CSharp`, `Format-PowerShell`, and `Format-Markdown` functions. * Added new markdown test files to validate rendering features. * Updated build script to handle native runtime libraries more effectively. * Refactored module manifest and project file for improved structure. --- Module/PSTextMate.psd1 | 10 +++---- PSTextMate.build.ps1 | 25 +++++++++++++++-- src/PSTextMate.csproj | 15 +++++++++- tests/Format-CSharp.tests.ps1 | 22 +++++++++++++++ tests/Format-Markdown.tests.ps1 | 17 +++++++++++ tests/Format-PowerShell.tests.ps1 | 22 +++++++++++++++ tests/Get-SupportedTextMate.tests.ps1 | 6 ++++ tests/Show-TextMate.tests.ps1 | 27 ++++++++++++++++++ tests/Test-SupportedTextMate.tests.ps1 | 14 ++++++++++ .../test-markdown-rendering.md | 0 {PSTextMate.Tests => tests}/test-markdown.md | 0 tests/testhelper.psm1 | 28 +++++++++++++++++++ 12 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 tests/Format-CSharp.tests.ps1 create mode 100644 tests/Format-Markdown.tests.ps1 create mode 100644 tests/Format-PowerShell.tests.ps1 create mode 100644 tests/Get-SupportedTextMate.tests.ps1 create mode 100644 tests/Show-TextMate.tests.ps1 create mode 100644 tests/Test-SupportedTextMate.tests.ps1 rename {PSTextMate.Tests => tests}/test-markdown-rendering.md (100%) rename {PSTextMate.Tests => tests}/test-markdown.md (100%) create mode 100644 tests/testhelper.psm1 diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index d8f59c7..d474aef 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -1,5 +1,5 @@ @{ - RootModule = 'lib/PSTextMate.dll' + RootModule = 'PSTextMate.dll' ModuleVersion = '0.1.0' GUID = 'a6490f8a-1f53-44f2-899c-bf66b9c6e608' Author = 'trackd' @@ -23,9 +23,9 @@ 'Show-Code' ) RequiredAssemblies = @( - './lib/TextMateSharp.dll' - './lib/TextMateSharp.Grammars.dll' - './lib/Onigwrap.dll' + 'lib/Onigwrap.dll' + 'lib/TextMateSharp.dll' + 'lib/TextMateSharp.Grammars.dll' 'Markdig.Signed.dll' ) FormatsToProcess = 'PSTextMate.format.ps1xml' @@ -33,7 +33,7 @@ @{ ModuleName = 'PwshSpectreConsole' ModuleVersion = '2.3.0' - MaximumVersion = '2.9.9' + MaximumVersion = '2.99.99' } ) } diff --git a/PSTextMate.build.ps1 b/PSTextMate.build.ps1 index 508311e..8d2b251 100644 --- a/PSTextMate.build.ps1 +++ b/PSTextMate.build.ps1 @@ -12,6 +12,7 @@ $modulename = [System.IO.Path]::GetFileName($PSCommandPath) -replace '\.build\.p $script:folders = @{ ModuleName = $modulename ProjectRoot = $PSScriptRoot + TempLib = Join-Path $PSScriptRoot 'templib' SourcePath = Join-Path $PSScriptRoot 'src' OutputPath = Join-Path $PSScriptRoot 'output' DestinationPath = Join-Path $PSScriptRoot 'output' 'lib' @@ -35,9 +36,21 @@ task Build { } try { Push-Location $folders.SourcePath - $buildOutput = dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.DestinationPath 2>&1 + + # exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.DestinationPath } + exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.TempLib } if ($LASTEXITCODE -ne 0) { - throw "Build failed:`n$buildOutput" + throw "Build failed with exit code $LASTEXITCODE" + } + New-Item -Path $folders.outputPath -ItemType Directory -Force | Out-Null + New-Item -Path $folders.DestinationPath -ItemType Directory -Force | Out-Null + Get-ChildItem -Path (Join-Path $folders.TempLib 'runtimes' 'win-x64' 'native') -Filter *.dll | Move-Item -Destination $folders.DestinationPath -Force + Get-ChildItem -Path (Join-Path $folders.TempLib 'runtimes' 'osx-arm64' 'native') -Filter *.dylib | Move-Item -Destination $folders.DestinationPath -Force + Get-ChildItem -Path (Join-Path $folders.TempLib 'runtimes' 'linux-x64' 'native') -Filter *.so | Copy-Item -Destination $folders.DestinationPath -Force + Move-Item (Join-Path $folders.TempLib 'PSTextMate.dll') -Destination $folders.OutputPath -Force + Get-ChildItem "$($folders.TempLib)/*.dll" | Move-Item -Destination $folders.DestinationPath -Force + if (Test-Path -Path $folders.TempLib -PathType Container) { + Remove-Item -Path $folders.TempLib -Recurse -Force -ErrorAction 'Ignore' } } finally { @@ -98,6 +111,12 @@ task Test -if (-not $SkipTests) { Write-Warning "Test directory not found at: $($folders.TestPath)" return } + $ParentPath = Split-Path $folders.ProjectRoot -Parent + Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + + Import-Module (Join-Path $folders.OutputPath 'PSTextMate.psd1') -ErrorAction Stop + Import-Module (Join-Path $folders.TestPath 'testhelper.psm1') -ErrorAction Stop + $pesterConfig = New-PesterConfiguration # $pesterConfig.Output.Verbosity = 'Detailed' $pesterConfig.Run.Path = $folders.TestPath @@ -112,5 +131,5 @@ task CleanAfter { } -task All -Jobs Clean, Build, ModuleFiles, GenerateHelp, CleanAfter #, Test +task All -Jobs Clean, Build, ModuleFiles, GenerateHelp, CleanAfter , Test task BuildAndTest -Jobs Clean, Build, ModuleFiles, CleanAfter #, Test diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index e1bc255..c4da0cf 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -7,7 +7,7 @@ enable 13.0 true - win-x64;linux-x64;osx-arm64 + @@ -37,4 +37,17 @@ + + + diff --git a/tests/Format-CSharp.tests.ps1 b/tests/Format-CSharp.tests.ps1 new file mode 100644 index 0000000..0b2f9bd --- /dev/null +++ b/tests/Format-CSharp.tests.ps1 @@ -0,0 +1,22 @@ +Describe 'Format-CSharp' { + It 'Formats a simple C# string and returns renderables' { + $code = 'public class Foo { }' + $out = $code | Format-CSharp + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered | Should -Match 'class|public class|namespace' + } + + It 'Formats a C# file and returns renderables' { + $temp = Join-Path $PSScriptRoot 'temp.cs' + 'public class Temp { }' | Out-File -FilePath $temp -Encoding utf8 + try { + $out = (Get-Item $temp) | Format-CSharp + $out | Should -Not -BeNullOrEmpty + $renderedFile = _GetSpectreRenderable $out -EscapeAnsi + $renderedFile | Should -Match 'class|public class|namespace' + } finally { + Remove-Item -Force -ErrorAction SilentlyContinue $temp + } + } +} diff --git a/tests/Format-Markdown.tests.ps1 b/tests/Format-Markdown.tests.ps1 new file mode 100644 index 0000000..e572887 --- /dev/null +++ b/tests/Format-Markdown.tests.ps1 @@ -0,0 +1,17 @@ +Describe 'Format-Markdown' { + It 'Formats Markdown and returns renderables' { + $md = "# Title\n\nSome text" + $out = $md | Format-Markdown + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered | Should -Match '# Title|Title|Some text' + } + + It 'Formats Markdown with Alternate and returns renderables' { + $md = "# Title\n\nSome text" + $out = $md | Format-Markdown -Alternate + $out | Should -Not -BeNullOrEmpty + $renderedAlt = _GetSpectreRenderable $out -EscapeAnsi + $renderedAlt | Should -Match '# Title|Title|Some text' + } +} diff --git a/tests/Format-PowerShell.tests.ps1 b/tests/Format-PowerShell.tests.ps1 new file mode 100644 index 0000000..c9141d7 --- /dev/null +++ b/tests/Format-PowerShell.tests.ps1 @@ -0,0 +1,22 @@ +Describe 'Format-PowerShell' { + It 'Formats a simple PowerShell string and returns renderables' { + $ps = 'function Test-Thing { Write-Output "hi" }' + $out = $ps | Format-PowerShell + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered | Should -Match 'function|Write-Output' + } + + It 'Formats a PowerShell file and returns renderables' { + $filename = Join-Path $PSScriptRoot ('{0}.ps1' -f (Get-Random)) + 'function Temp { Write-Output "ok" }' | Set-Content -Path $filename + try { + $out = (Get-Item $filename) | Format-PowerShell + $out | Should -Not -BeNullOrEmpty + $renderedFile = _GetSpectreRenderable $out -EscapeAnsi + $renderedFile | Should -Match 'function|Write-Output' + } finally { + Remove-Item -Force -ErrorAction SilentlyContinue $filename + } + } +} diff --git a/tests/Get-SupportedTextMate.tests.ps1 b/tests/Get-SupportedTextMate.tests.ps1 new file mode 100644 index 0000000..744b413 --- /dev/null +++ b/tests/Get-SupportedTextMate.tests.ps1 @@ -0,0 +1,6 @@ +Describe 'Get-SupportedTextMate' { + It 'Returns at least one available language' { + $result = Get-SupportedTextMate + $result | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Show-TextMate.tests.ps1 b/tests/Show-TextMate.tests.ps1 new file mode 100644 index 0000000..12e9f75 --- /dev/null +++ b/tests/Show-TextMate.tests.ps1 @@ -0,0 +1,27 @@ +BeforeAll { + $psString = @' +function Foo-Bar { + param([string]$Name) + Write-Host "Hello, $Name!" +} +'@ + $psowrapped = [psobject]::new($psString) + $note = [PSNoteProperty]::new('PSChildName', 'FooBar.ps1') + $psowrapped.psobject.properties.add($note) +} + +Describe 'Show-TextMate' { + It 'Formats a PSObject with PSChildName and returns rendered output containing the filename' { + $out2 = $psowrapped | Show-TextMate + $out2 | Should -Not -BeNullOrEmpty + $rendered2 = _GetSpectreRenderable @($out2) -EscapeAnsi + $rendered2 | Should -Match 'FooBar|Foo-Bar' + } + It "Can render markdown" { + $file = Get-Item -Path (Join-Path $PSScriptRoot 'test-markdown.md') + $out = $file | Show-TextMate + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered | Should -Match '# Markdown Rendering Test File' + } +} diff --git a/tests/Test-SupportedTextMate.tests.ps1 b/tests/Test-SupportedTextMate.tests.ps1 new file mode 100644 index 0000000..a6a1c68 --- /dev/null +++ b/tests/Test-SupportedTextMate.tests.ps1 @@ -0,0 +1,14 @@ +Describe 'Test-SupportedTextMate' { + It 'Recognizes powershell language' { + Test-SupportedTextMate -Language 'powershell' | Should -BeTrue + } + + It 'Recognizes .ps1 extension' { + Test-SupportedTextMate -Extension '.ps1' | Should -BeTrue + } + + It 'Recognizes an existing file as supported' { + $testFile = Join-Path $PSScriptRoot 'Show-TextMate.tests.ps1' + Test-SupportedTextMate -File $testFile | Should -BeTrue + } +} diff --git a/PSTextMate.Tests/test-markdown-rendering.md b/tests/test-markdown-rendering.md similarity index 100% rename from PSTextMate.Tests/test-markdown-rendering.md rename to tests/test-markdown-rendering.md diff --git a/PSTextMate.Tests/test-markdown.md b/tests/test-markdown.md similarity index 100% rename from PSTextMate.Tests/test-markdown.md rename to tests/test-markdown.md diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 new file mode 100644 index 0000000..d15fbf7 --- /dev/null +++ b/tests/testhelper.psm1 @@ -0,0 +1,28 @@ +function _GetSpectreRenderable { + param( + [Parameter(Mandatory)] + [object] $RenderableObject, + [switch] $EscapeAnsi + ) + try { + [Spectre.Console.Rendering.Renderable]$RenderableObject = $RenderableObject + $writer = [System.IO.StringWriter]::new() + $output = [Spectre.Console.AnsiConsoleOutput]::new($writer) + $settings = [Spectre.Console.AnsiConsoleSettings]::new() + $settings.Out = $output + $console = [Spectre.Console.AnsiConsole]::Create($settings) + $console.Write($RenderableObject) + if ($EscapeAnsi) { + return $writer.ToString() | _EscapeAnsi + } + $writer.ToString() + } + finally { + $writer.Dispose() + } +} +filter _EscapeAnsi { + [System.Management.Automation.Host.PSHostUserInterface]::GetOutputString($_, $false) +} + +Export-ModuleMember -Function _GetSpectreRenderable, _EscapeAnsi From b10d75176cb12c3ad076de5eb38e2da667937aa2 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:36:52 +0100 Subject: [PATCH 16/25] =?UTF-8?q?feat(cmdlets):=20=E2=9C=A8=20Add=20-Lines?= =?UTF-8?q?=20parameter=20to=20output=20single=20HighlightedText=20contain?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced a new `-Lines` switch parameter in `ShowTextMateCmdlet` and `TextMateCmdletBase` to control output format. * Updated processing logic to conditionally output either a collection of renderables or a single HighlightedText object. * Enhanced tests for `Format-CSharp`, `Format-Markdown`, `Format-PowerShell`, and `Show-TextMate` to validate new functionality. --- src/Cmdlets/ShowTextMateCmdlet.cs | 28 +++++- src/Cmdlets/TextMateCmdletBase.cs | 30 ++++++- src/Core/HighlightedText.cs | 17 +--- src/Core/TextMateProcessor.cs | 137 +----------------------------- tests/Format-CSharp.tests.ps1 | 16 +++- tests/Format-Markdown.tests.ps1 | 13 ++- tests/Format-PowerShell.tests.ps1 | 14 ++- tests/Show-TextMate.tests.ps1 | 15 +++- 8 files changed, 98 insertions(+), 172 deletions(-) diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index bcf5122..15f52f0 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -54,12 +54,23 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter] public SwitchParameter Alternate { get; set; } + /// + /// When present, output a single HighlightedText container instead of enumerating renderables. + /// + [Parameter] + public SwitchParameter Lines { get; set; } + protected override void ProcessRecord() { if (MyInvocation.ExpectingInput) { if (InputObject?.BaseObject is FileInfo file) { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result.Renderables, enumerateCollection: true); + if (Lines.IsPresent) { + WriteObject(result.Renderables, enumerateCollection: true); + } + else { + WriteObject(result); + } } } catch (Exception ex) { @@ -81,7 +92,12 @@ protected override void ProcessRecord() { if (!file.Exists) return; try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result.Renderables, enumerateCollection: true); + if (Lines.IsPresent) { + WriteObject(result.Renderables, enumerateCollection: true); + } + else { + WriteObject(result); + } } } catch (Exception ex) { @@ -105,8 +121,12 @@ protected override void EndProcessing() { } HighlightedText? result = ProcessStringInput(); if (result is not null) { - // Output each renderable directly so pwshspectreconsole can format them - WriteObject(result.Renderables, enumerateCollection: true); + if (Lines.IsPresent) { + WriteObject(result.Renderables, enumerateCollection: true); + } + else { + WriteObject(result); + } } } catch (Exception ex) { diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs index 43ef768..b5a0f34 100644 --- a/src/Cmdlets/TextMateCmdletBase.cs +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -34,6 +34,12 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + /// + /// When present, output a single HighlightedText container instead of enumerating renderables. + /// + [Parameter] + public SwitchParameter Lines { get; set; } + /// /// Fixed language or extension token used for rendering. /// @@ -64,7 +70,13 @@ protected override void ProcessRecord() { if (InputObject?.BaseObject is FileInfo file) { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result.Renderables, enumerateCollection: true); + if (Lines.IsPresent) { + WriteObject(result.Renderables, enumerateCollection: true); + } + else + { + WriteObject(result); + } } } catch (Exception ex) { @@ -87,7 +99,13 @@ protected override void ProcessRecord() { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result.Renderables, enumerateCollection: true); + if (Lines.IsPresent) { + WriteObject(result.Renderables, enumerateCollection: true); + } + else + { + WriteObject(result); + } } } catch (Exception ex) { @@ -108,7 +126,13 @@ protected override void EndProcessing() { HighlightedText? result = ProcessStringInput(); if (result is not null) { - WriteObject(result.Renderables, enumerateCollection: true); + if (Lines.IsPresent) { + WriteObject(result.Renderables, enumerateCollection: true); + } + else + { + WriteObject(result); + } } } catch (Exception ex) { diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs index d67b49c..eb1dc90 100644 --- a/src/Core/HighlightedText.cs +++ b/src/Core/HighlightedText.cs @@ -5,7 +5,7 @@ namespace PSTextMate.Core; /// /// Represents syntax-highlighted text ready for rendering. -/// Provides a clean, consistent output type with optional metadata for streaming scenarios. +/// Provides a clean, consistent output type. /// Implements IRenderable so it can be used directly with Spectre.Console. /// public sealed class HighlightedText : Renderable { @@ -19,21 +19,6 @@ public sealed class HighlightedText : Renderable { /// public int LineCount => Renderables.Length; - /// - /// Optional batch index for streaming scenarios (null for single-batch operations). - /// - public int? BatchIndex { get; init; } - - /// - /// Optional file offset (starting line number) for streaming scenarios (null for single-batch operations). - /// - public long? FileOffset { get; init; } - - /// - /// Indicates whether this is part of a streaming operation. - /// - public bool IsStreaming => BatchIndex.HasValue && FileOffset.HasValue; - /// /// Renders the highlighted text by combining all renderables into a single output. /// diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 1671d28..5fea91f 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -121,140 +121,5 @@ private static IRenderable[] RenderCodeBlock(string[] lines, Theme theme, IGramm } } - /// - /// Processes an enumerable of lines in batches to support streaming/low-memory processing. - /// Yields a HighlightedText result for each processed batch with metadata. - /// - /// Enumerable of text lines to process - /// Number of lines to process per batch (default: 1000 lines balances memory usage with throughput) - /// Theme to apply for styling - /// Language ID or file extension for grammar selection - /// True if grammarId is a file extension, false if it's a language ID - /// Token to monitor for cancellation requests - /// Optional progress reporter for tracking processing status - /// Enumerable of HighlightedText objects containing processed lines with batch metadata - /// Thrown when is null - /// Thrown when is less than or equal to zero - /// Thrown when grammar cannot be found - /// Thrown when cancellation is requested - /// - /// Batch size considerations: - /// - Smaller batches (100-500): Lower memory, more frequent progress updates, slightly higher overhead - /// - Default (1000): Balanced approach for most scenarios - /// - Larger batches (2000-5000): Better throughput for large files, higher memory usage - /// - public static IEnumerable ProcessLinesInBatches( - IEnumerable lines, - int batchSize, - ThemeName themeName, - string grammarId, - bool isExtension = false, - bool forceAlternate = false, - bool useMarkdownLayout = false, - IProgress<(int batchIndex, long linesProcessed)>? progress = null, - CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize, nameof(batchSize)); - - var buffer = new List(batchSize); - int batchIndex = 0; - long fileOffset = 0; // starting line index for the next batch - - // Load theme and registry once and then resolve the requested grammar scope - // directly on the registry. Avoid using the global grammar cache here because - // TextMateSharp's Registry manages its own internal grammar store and repeated - // LoadGrammar calls or cross-registry caching can cause duplicate-key exceptions. - (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); - // Resolve grammar using CacheManager which knows how to map language ids and extensions - IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); - bool useMarkdownRenderer = grammar.GetName() == "Markdown" && !forceAlternate; - // If explicitly requested, prefer the Markdown layout even when forceAlternate is used - if (grammar.GetName() == "Markdown" && useMarkdownLayout) { - useMarkdownRenderer = true; - } - - foreach (string? line in lines) { - cancellationToken.ThrowIfCancellationRequested(); - - buffer.Add(line ?? string.Empty); - if (buffer.Count >= batchSize) { - // Render the batch using the already-loaded grammar and theme - IRenderable[]? result = useMarkdownRenderer - ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName) - : StandardRenderer.Render([.. buffer], theme, grammar); - - if (result is not null) { - yield return new HighlightedText { - Renderables = result, - BatchIndex = batchIndex, - FileOffset = fileOffset - }; - progress?.Report((batchIndex, fileOffset + batchSize)); - batchIndex++; - } - buffer.Clear(); - fileOffset += batchSize; - } - } - - // Process remaining lines - if (buffer.Count > 0) { - cancellationToken.ThrowIfCancellationRequested(); - - IRenderable[]? result = useMarkdownRenderer - ? MarkdownRenderer.Render([.. buffer], theme, grammar, themeName) - : StandardRenderer.Render([.. buffer], theme, grammar); - - if (result is not null) { - yield return new HighlightedText { - Renderables = result, - BatchIndex = batchIndex, - FileOffset = fileOffset - }; - progress?.Report((batchIndex, fileOffset + buffer.Count)); - } - } - } - - /// - /// Backward compatibility overload without cancellation and progress support. - /// - public static IEnumerable ProcessLinesInBatches(IEnumerable lines, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) - => ProcessLinesInBatches(lines, batchSize, themeName, grammarId, isExtension, false, false, null, CancellationToken.None); - - /// - /// Helper to stream a file by reading lines lazily and processing them in batches. - /// - /// Path to the file to process - /// Number of lines to process per batch - /// Theme to apply for styling - /// Language ID or file extension for grammar selection - /// True if grammarId is a file extension, false if it's a language ID - /// Token to monitor for cancellation requests - /// Optional progress reporter for tracking processing status - /// Enumerable of HighlightedText objects containing processed lines with batch metadata - /// Thrown when the specified file does not exist - /// Thrown when lines enumerable is null - /// Thrown when batchSize is less than or equal to zero - /// Thrown when grammar cannot be found - /// Thrown when cancellation is requested - public static IEnumerable ProcessFileInBatches( - string filePath, - int batchSize, - ThemeName themeName, - string grammarId, - bool isExtension = false, - bool forceAlternate = false, - bool useMarkdownLayout = false, - IProgress<(int batchIndex, long linesProcessed)>? progress = null, - CancellationToken cancellationToken = default) { - return !File.Exists(filePath) - ? throw new FileNotFoundException(filePath) - : ProcessLinesInBatches(File.ReadLines(filePath), batchSize, themeName, grammarId, isExtension, forceAlternate, useMarkdownLayout, progress, cancellationToken); - } - - /// - /// Backward compatibility overload without cancellation and progress support. - /// - public static IEnumerable ProcessFileInBatches(string filePath, int batchSize, ThemeName themeName, string grammarId, bool isExtension = false) => ProcessFileInBatches(filePath, batchSize, themeName, grammarId, isExtension, false, false, null, CancellationToken.None); + // Batch/streaming helpers removed; keep rendering paths focused on single-pass processing. } diff --git a/tests/Format-CSharp.tests.ps1 b/tests/Format-CSharp.tests.ps1 index 0b2f9bd..5ad82fd 100644 --- a/tests/Format-CSharp.tests.ps1 +++ b/tests/Format-CSharp.tests.ps1 @@ -3,18 +3,26 @@ Describe 'Format-CSharp' { $code = 'public class Foo { }' $out = $code | Format-CSharp $out | Should -Not -BeNullOrEmpty - $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi $rendered | Should -Match 'class|public class|namespace' } + It 'Outputs every single line when -Lines is used' { + $code = 'public class Foo { }' + $lines = $code | Format-CSharp -Lines + $lines | Should -BeOfType Spectre.Console.Paragraph + $rendered = $lines | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ -EscapeAnsi } | Out-String + $rendered | Should -Match 'class|public class|namespace' + } + It 'Formats a C# file and returns renderables' { $temp = Join-Path $PSScriptRoot 'temp.cs' 'public class Temp { }' | Out-File -FilePath $temp -Encoding utf8 try { - $out = (Get-Item $temp) | Format-CSharp + $out = Get-Item $temp | Format-CSharp $out | Should -Not -BeNullOrEmpty - $renderedFile = _GetSpectreRenderable $out -EscapeAnsi - $renderedFile | Should -Match 'class|public class|namespace' + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'class|public class|namespace' } finally { Remove-Item -Force -ErrorAction SilentlyContinue $temp } diff --git a/tests/Format-Markdown.tests.ps1 b/tests/Format-Markdown.tests.ps1 index e572887..878d0cb 100644 --- a/tests/Format-Markdown.tests.ps1 +++ b/tests/Format-Markdown.tests.ps1 @@ -3,7 +3,16 @@ Describe 'Format-Markdown' { $md = "# Title\n\nSome text" $out = $md | Format-Markdown $out | Should -Not -BeNullOrEmpty - $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered = _GetSpectreRenderable -RenderableObject $out + $rendered | Should -Match '# Title|Title|Some text' + } + + It 'Outputs every single line when -Lines is used' { + $md = "# Title\n\nSome text" + $Lines = $md | Format-Markdown -Lines + $Lines | Should -BeOfType Spectre.Console.Paragraph + $rendered = _GetSpectreRenderable -RenderableObject $Lines + $rendered = $Lines | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ } | Out-String $rendered | Should -Match '# Title|Title|Some text' } @@ -11,7 +20,7 @@ Describe 'Format-Markdown' { $md = "# Title\n\nSome text" $out = $md | Format-Markdown -Alternate $out | Should -Not -BeNullOrEmpty - $renderedAlt = _GetSpectreRenderable $out -EscapeAnsi + $renderedAlt = _GetSpectreRenderable -RenderableObject $out $renderedAlt | Should -Match '# Title|Title|Some text' } } diff --git a/tests/Format-PowerShell.tests.ps1 b/tests/Format-PowerShell.tests.ps1 index c9141d7..991153e 100644 --- a/tests/Format-PowerShell.tests.ps1 +++ b/tests/Format-PowerShell.tests.ps1 @@ -3,7 +3,15 @@ Describe 'Format-PowerShell' { $ps = 'function Test-Thing { Write-Output "hi" }' $out = $ps | Format-PowerShell $out | Should -Not -BeNullOrEmpty - $rendered = _GetSpectreRenderable $out -EscapeAnsi + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'function|Write-Output' + } + + It 'Outputs every single line when -Lines is used' { + $ps = 'function Test-Thing { Write-Output "hi" }' + $lines = $ps | Format-PowerShell -Lines + $lines | Should -BeOfType Spectre.Console.Paragraph + $rendered = $lines | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ -EscapeAnsi } | Out-String $rendered | Should -Match 'function|Write-Output' } @@ -11,9 +19,9 @@ Describe 'Format-PowerShell' { $filename = Join-Path $PSScriptRoot ('{0}.ps1' -f (Get-Random)) 'function Temp { Write-Output "ok" }' | Set-Content -Path $filename try { - $out = (Get-Item $filename) | Format-PowerShell + $out = Get-Item $filename | Format-PowerShell $out | Should -Not -BeNullOrEmpty - $renderedFile = _GetSpectreRenderable $out -EscapeAnsi + $renderedFile = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi $renderedFile | Should -Match 'function|Write-Output' } finally { Remove-Item -Force -ErrorAction SilentlyContinue $filename diff --git a/tests/Show-TextMate.tests.ps1 b/tests/Show-TextMate.tests.ps1 index 12e9f75..d59c0c1 100644 --- a/tests/Show-TextMate.tests.ps1 +++ b/tests/Show-TextMate.tests.ps1 @@ -14,14 +14,21 @@ Describe 'Show-TextMate' { It 'Formats a PSObject with PSChildName and returns rendered output containing the filename' { $out2 = $psowrapped | Show-TextMate $out2 | Should -Not -BeNullOrEmpty - $rendered2 = _GetSpectreRenderable @($out2) -EscapeAnsi - $rendered2 | Should -Match 'FooBar|Foo-Bar' + $rendered = _GetSpectreRenderable -RenderableObject $out2 -EscapeAnsi + $rendered | Should -Match 'FooBar|Foo-Bar' + } + It 'Outputs every single line when -Lines is used' { + $out = $psString | Show-TextMate -Lines + $out | Should -BeOfType Spectre.Console.Paragraph + $rendered = $out | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ -EscapeAnsi } | Out-String + $rendered | Should -Match 'function|Write-Host|Foo-Bar' } It "Can render markdown" { $file = Get-Item -Path (Join-Path $PSScriptRoot 'test-markdown.md') $out = $file | Show-TextMate $out | Should -Not -BeNullOrEmpty - $rendered = _GetSpectreRenderable $out -EscapeAnsi - $rendered | Should -Match '# Markdown Rendering Test File' + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'Markdown Test File' + $rendered | Should -Match 'Path.GetExtension' } } From 5d94cea263803b45725a62787e49870ba72c22fd Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:13:00 +0100 Subject: [PATCH 17/25] =?UTF-8?q?feat(tests):=20=E2=9C=A8=20Update=20tests?= =?UTF-8?q?=20for=20formatting=20C#,=20PowerShell,=20and=20Markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor tests to remove the -Lines parameter and ensure they validate rendered output correctly. * Enhance test descriptions for clarity and maintainability. * Add new test cases for incomplete tasks in Markdown. --- src/Cmdlets/ShowTextMateCmdlet.cs | 23 +----------- src/Cmdlets/TextMateCmdletBase.cs | 25 ++----------- src/Core/StandardRenderer.cs | 17 +++++---- src/Core/TextMateProcessor.cs | 34 +++++++----------- src/Core/TokenProcessor.cs | 49 ++++--------------------- src/Rendering/CodeBlockRenderer.cs | 10 +++--- src/Rendering/MarkdownRenderer.cs | 2 +- src/Rendering/ParagraphRenderer.cs | 24 ++++++++----- src/Rendering/QuoteRenderer.cs | 54 ++++++++++------------------ src/Rendering/TableRenderer.cs | 14 ++++---- src/Utilities/InlineTextExtractor.cs | 1 + tests/Format-CSharp.tests.ps1 | 13 ++++--- tests/Format-Markdown.tests.ps1 | 8 ++--- tests/Format-PowerShell.tests.ps1 | 7 ++-- tests/Show-TextMate.tests.ps1 | 9 +++-- tests/test-markdown.md | 2 ++ 16 files changed, 96 insertions(+), 196 deletions(-) diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index 15f52f0..de5a541 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -54,23 +54,12 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter] public SwitchParameter Alternate { get; set; } - /// - /// When present, output a single HighlightedText container instead of enumerating renderables. - /// - [Parameter] - public SwitchParameter Lines { get; set; } - protected override void ProcessRecord() { if (MyInvocation.ExpectingInput) { if (InputObject?.BaseObject is FileInfo file) { try { foreach (HighlightedText result in ProcessPathInput(file)) { - if (Lines.IsPresent) { - WriteObject(result.Renderables, enumerateCollection: true); - } - else { WriteObject(result); - } } } catch (Exception ex) { @@ -92,12 +81,7 @@ protected override void ProcessRecord() { if (!file.Exists) return; try { foreach (HighlightedText result in ProcessPathInput(file)) { - if (Lines.IsPresent) { - WriteObject(result.Renderables, enumerateCollection: true); - } - else { WriteObject(result); - } } } catch (Exception ex) { @@ -121,12 +105,7 @@ protected override void EndProcessing() { } HighlightedText? result = ProcessStringInput(); if (result is not null) { - if (Lines.IsPresent) { - WriteObject(result.Renderables, enumerateCollection: true); - } - else { - WriteObject(result); - } + WriteObject(result); } } catch (Exception ex) { diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs index b5a0f34..87107e3 100644 --- a/src/Cmdlets/TextMateCmdletBase.cs +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -34,11 +34,6 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public ThemeName Theme { get; set; } = ThemeName.DarkPlus; - /// - /// When present, output a single HighlightedText container instead of enumerating renderables. - /// - [Parameter] - public SwitchParameter Lines { get; set; } /// /// Fixed language or extension token used for rendering. @@ -70,13 +65,8 @@ protected override void ProcessRecord() { if (InputObject?.BaseObject is FileInfo file) { try { foreach (HighlightedText result in ProcessPathInput(file)) { - if (Lines.IsPresent) { - WriteObject(result.Renderables, enumerateCollection: true); - } - else - { + WriteObject(result); - } } } catch (Exception ex) { @@ -99,13 +89,8 @@ protected override void ProcessRecord() { try { foreach (HighlightedText result in ProcessPathInput(file)) { - if (Lines.IsPresent) { - WriteObject(result.Renderables, enumerateCollection: true); - } - else - { + WriteObject(result); - } } } catch (Exception ex) { @@ -126,13 +111,7 @@ protected override void EndProcessing() { HighlightedText? result = ProcessStringInput(); if (result is not null) { - if (Lines.IsPresent) { - WriteObject(result.Renderables, enumerateCollection: true); - } - else - { WriteObject(result); - } } } catch (Exception ex) { diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 9e7ce34..e2dbf15 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -28,18 +28,17 @@ public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar try { IStateStack? ruleStack = null; for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { - string line = lines[lineIndex]; - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - - if (string.IsNullOrEmpty(line)) { - rows.Add(Text.Empty); + if (string.IsNullOrEmpty(lines[lineIndex])) + { + rows.Add(new Rows(Text.Empty)); continue; } - var paragraph = new Paragraph(); - TokenProcessor.ProcessTokensToParagraph(result.Tokens, line, theme, paragraph); - rows.Add(paragraph); + ITokenizeLineResult result = grammar.TokenizeLine(lines[lineIndex], ruleStack, TimeSpan.MaxValue); + ruleStack = result.RuleStack; + + TokenProcessor.ProcessTokensToParagraph(result.Tokens, lines[lineIndex], theme, paragraph); + rows.Add(new Rows(paragraph)); } return [.. rows]; diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 5fea91f..5822220 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -97,29 +97,21 @@ public static class TextMateProcessor { /// Renders code block lines without escaping markup characters. /// private static IRenderable[] RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) { - StringBuilder builder = StringBuilderPool.Rent(); - try { - List rows = new(lines.Length); - IStateStack? ruleStack = null; - - for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { - string line = lines[lineIndex]; - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - TokenProcessor.ProcessTokensBatch(result.Tokens, line, theme, builder, lineIndex, escapeMarkup: false); - string lineMarkup = builder.ToString(); - // Use Markup to parse the color codes generated by TextMateProcessor - // If markup is empty, use an empty Text object instead - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); - builder.Clear(); + List rows = new(lines.Length); + IStateStack? ruleStack = null; + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { + if (String.IsNullOrEmpty(lines[lineIndex])) + { + rows.Add(new Rows(Text.Empty)); + continue; } - - return [.. rows]; - } - finally { - StringBuilderPool.Return(builder); + var paragraph = new Paragraph(); + ITokenizeLineResult result = grammar.TokenizeLine(lines[lineIndex], ruleStack, TimeSpan.MaxValue); + ruleStack = result.RuleStack; + TokenProcessor.ProcessTokensToParagraph(result.Tokens, lines[lineIndex], theme, paragraph); + rows.Add(new Rows(paragraph)); } + return [.. rows]; } - // Batch/streaming helpers removed; keep rendering paths focused on single-pass processing. } diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index f757fc7..24149e2 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -17,45 +17,6 @@ internal static class TokenProcessor { // Cache Style results per (scopesKey, themeInstanceHash) private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> _styleCache = new(); - /// - /// Processes tokens in batches for better cache locality and performance. - /// This version uses the style cache to avoid re-creating Style objects per token - /// and appends text directly into the provided StringBuilder to avoid temporary strings. - /// - /// Tokens to process - /// Source line text - /// Theme for styling - /// StringBuilder for output - /// Line index for debugging context - /// Whether to escape markup characters (true for normal text, false for code blocks) - public static void ProcessTokensBatch( - IToken[] tokens, - string line, - Theme theme, - StringBuilder builder, - int? lineIndex = null, - bool escapeMarkup = true) { - foreach (IToken token in tokens) { - int startIndex = Math.Min(token.StartIndex, line.Length); - int endIndex = Math.Min(token.EndIndex, line.Length); - - if (startIndex >= endIndex) continue; - - ReadOnlySpan textSpan = line.SpanSubstring(startIndex, endIndex); - - // Use cached Style where possible to avoid rebuilding Style objects per token - Style? style = GetStyleForScopes(token.Scopes, theme); - - // Only extract numeric theme properties when debugging is enabled to reduce work - (int foreground, int background, FontStyle fontStyle) = (-1, -1, FontStyle.NotSet); - - // Use the returning API so callers can append with style consistently (prevents markup regressions) - (string processedText, Style? resolvedStyle) = WriteTokenReturn(textSpan, style, theme, escapeMarkup); - builder.AppendWithStyle(resolvedStyle, processedText); - - } - } - public static (int foreground, int background, FontStyle fontStyle) ExtractThemeProperties(IToken token, Theme theme) { // Build a compact key from token scopes (they're mostly immutable per token) string scopesKey = string.Join('\u001F', token.Scopes); @@ -211,16 +172,17 @@ public static void ProcessTokensToParagraph( } } } - /// /// Returns a cached Style for the given scopes and theme. Returns null for default/no-style. /// - public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { + public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) + { string scopesKey = string.Join('\u001F', scopes); int themeHash = RuntimeHelpers.GetHashCode(theme); (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); - if (_styleCache.TryGetValue(cacheKey, out Style? cached)) { + if (_styleCache.TryGetValue(cacheKey, out Style? cached)) + { return cached; } @@ -228,7 +190,8 @@ public static void ProcessTokensToParagraph( // Create a dummy token-like enumerable for existing ExtractThemeProperties method var token = new MarkdownToken([.. scopes]); (int fg, int bg, FontStyle fs) = ExtractThemeProperties(token, theme); - if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) { + if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) + { _styleCache.TryAdd(cacheKey, null); return null; } diff --git a/src/Rendering/CodeBlockRenderer.cs b/src/Rendering/CodeBlockRenderer.cs index fc25a3c..44a2df9 100644 --- a/src/Rendering/CodeBlockRenderer.cs +++ b/src/Rendering/CodeBlockRenderer.cs @@ -28,7 +28,7 @@ internal static class CodeBlockRenderer { /// Rendered code block in a panel public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Theme theme, ThemeName themeName) { string[] codeLines = ExtractCodeLinesWithWhitespaceHandling(fencedCode.Lines); - string language = ExtractLanguageImproved(fencedCode.Info); + string language = ExtractLanguage(fencedCode.Info); if (!string.IsNullOrEmpty(language)) { try { @@ -45,7 +45,7 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them } // Fallback: create Text object directly instead of markup strings - return CreateOptimizedCodePanel(codeLines, language, theme); + return CreateCodePanel(codeLines, language, theme); } /// /// Renders an indented code block with proper whitespace handling. /// @@ -54,7 +54,7 @@ public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Them /// Rendered code block in a panel public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) { string[] codeLines = ExtractCodeLinesFromStringLineGroup(code.Lines); - return CreateOptimizedCodePanel(codeLines, "code", theme); + return CreateCodePanel(codeLines, "code", theme); } /// @@ -111,7 +111,7 @@ private static string[] ExtractCodeLinesFromStringLineGroup(StringLineGroup line /// /// Improved language extraction with better detection patterns. /// - private static string ExtractLanguageImproved(string? info) { + private static string ExtractLanguage(string? info) { if (string.IsNullOrWhiteSpace(info)) return string.Empty; @@ -197,7 +197,7 @@ private static string[] RemoveTrailingEmptyLines(string[] lines) { /// Creates an optimized code panel using Text objects instead of markup strings. /// This eliminates VT escaping issues and improves performance. /// - private static Panel CreateOptimizedCodePanel(string[] codeLines, string language, Theme theme) { + private static Panel CreateCodePanel(string[] codeLines, string language, Theme theme) { // Get theme colors for code blocks string[] codeScopes = ["text.html.markdown", "markup.fenced_code.block.markdown"]; (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( diff --git a/src/Rendering/MarkdownRenderer.cs b/src/Rendering/MarkdownRenderer.cs index a38b4ac..5279221 100644 --- a/src/Rendering/MarkdownRenderer.cs +++ b/src/Rendering/MarkdownRenderer.cs @@ -53,7 +53,7 @@ public static IRenderable[] Render(string markdown, Theme theme, ThemeName theme int previousEndLine = GetBlockEndLine(previousBlock, markdown); int gap = block.Line - previousEndLine - 1; for (int j = 0; j < gap; j++) { - rows.Add(Text.Empty); + rows.Add(new Rows(Text.Empty)); } } diff --git a/src/Rendering/ParagraphRenderer.cs b/src/Rendering/ParagraphRenderer.cs index d1d7fd7..d005526 100644 --- a/src/Rendering/ParagraphRenderer.cs +++ b/src/Rendering/ParagraphRenderer.cs @@ -29,12 +29,13 @@ internal static partial class ParagraphRenderer { /// /// The paragraph block to render /// Theme for styling + /// When true, emits one Paragraph per line break /// Text segments with proper styling - public static IEnumerable Render(ParagraphBlock paragraph, Theme theme) { + public static IEnumerable Render(ParagraphBlock paragraph, Theme theme, bool splitOnLineBreaks = false) { var segments = new List(); if (paragraph.Inline is not null) { - BuildTextSegments(segments, paragraph.Inline, theme); + BuildTextSegments(segments, paragraph.Inline, theme, splitOnLineBreaks: splitOnLineBreaks); } return segments; @@ -44,8 +45,8 @@ public static IEnumerable Render(ParagraphBlock paragraph, Theme th /// Builds Text segments from inline elements with proper Style objects. /// Accumulates plain text and flushes when style changes (code, links). /// - private static void BuildTextSegments(List segments, ContainerInline inlines, Theme theme, bool skipLineBreaks = false) { - var paragraph = new Paragraph(); + private static void BuildTextSegments(List segments, ContainerInline inlines, Theme theme, bool skipLineBreaks = false, bool splitOnLineBreaks = false) { + Paragraph paragraph = new Paragraph(); bool addedAny = false; List inlineList = [.. inlines]; @@ -138,8 +139,17 @@ private static void BuildTextSegments(List segments, ContainerInlin case LineBreakInline: if (!skipLineBreaks && !isTrailingLineBreak) { - paragraph.Append("\n", Style.Plain); - addedAny = true; + if (splitOnLineBreaks) { + if (addedAny) { + segments.Add(paragraph); + } + paragraph = new Paragraph(); + addedAny = false; + } + else { + paragraph.Append("\n", Style.Plain); + addedAny = true; + } } break; @@ -290,8 +300,6 @@ private static bool TryParseUsernameLinks(string text, out TextSegment[] segment return true; } - - [GeneratedRegex(@"@[a-zA-Z0-9_-]+")] private static partial Regex RegNumLet(); } diff --git a/src/Rendering/QuoteRenderer.cs b/src/Rendering/QuoteRenderer.cs index 4a5d8b2..1ee56ba 100644 --- a/src/Rendering/QuoteRenderer.cs +++ b/src/Rendering/QuoteRenderer.cs @@ -1,6 +1,4 @@ -using System.Text; using Markdig.Syntax; -using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Themes; @@ -18,48 +16,32 @@ internal static class QuoteRenderer { /// Theme for styling /// Rendered quote in a bordered panel public static IRenderable Render(QuoteBlock quote, Theme theme) { - string quoteText = ExtractQuoteText(quote, theme); - - var text = new Text(quoteText, Style.Plain); - return new Panel(text) - .Border(BoxBorder.Heavy) - .Header("quote", Justify.Left); - } - - /// - /// Extracts text content from all blocks within the quote. - /// - private static string ExtractQuoteText(QuoteBlock quote, Theme theme) { - string quoteText = string.Empty; - bool isFirstParagraph = true; + var rows = new List(); + bool needsGap = false; foreach (Block subBlock in quote) { - if (subBlock is ParagraphBlock para) { - // Add newline between multiple paragraphs - if (!isFirstParagraph) - quoteText += "\n"; - - StringBuilder quoteBuilder = StringBuilderPool.Rent(); - try { - if (para.Inline is not null) { - InlineTextExtractor.ExtractText(para.Inline, quoteBuilder); - } - quoteText += quoteBuilder.ToString(); - } - finally { - StringBuilderPool.Return(quoteBuilder); - } + if (needsGap) { + rows.Add(Text.Empty); + } - isFirstParagraph = false; + if (subBlock is ParagraphBlock paragraph) { + rows.AddRange(ParagraphRenderer.Render(paragraph, theme, splitOnLineBreaks: true)); } else { - quoteText += subBlock.ToString(); + rows.Add(new Text(subBlock.ToString() ?? string.Empty, Style.Plain)); } + + needsGap = true; } - // Trim trailing whitespace/newlines - quoteText = quoteText.TrimEnd(); + IRenderable content = rows.Count switch { + 0 => Text.Empty, + 1 => rows[0], + _ => new Rows(rows) + }; - return quoteText; + return new Panel(content) + .Border(BoxBorder.Heavy) + .Header("quote", Justify.Left); } } diff --git a/src/Rendering/TableRenderer.cs b/src/Rendering/TableRenderer.cs index 96475ae..58c1eb7 100644 --- a/src/Rendering/TableRenderer.cs +++ b/src/Rendering/TableRenderer.cs @@ -32,7 +32,7 @@ internal static class TableRenderer { BorderStyle = GetTableBorderStyle(theme) }; - List<(bool isHeader, List cells)> allRows = ExtractTableDataOptimized(table, theme); + List<(bool isHeader, List cells)> allRows = ExtractTableData(table, theme); if (allRows.Count == 0) return null; @@ -98,9 +98,9 @@ internal static class TableRenderer { internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment); /// - /// Extracts table data with optimized cell content processing. + /// Extracts table data cells /// - internal static List<(bool isHeader, List cells)> ExtractTableDataOptimized( + internal static List<(bool isHeader, List cells)> ExtractTableData( Markdig.Extensions.Tables.Table table, Theme theme) { var result = new List<(bool isHeader, List cells)>(); @@ -110,7 +110,7 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment for (int i = 0; i < row.Count; i++) { if (row[i] is TableCell cell) { - string cellText = ExtractCellTextOptimized(cell, theme); + string cellText = ExtractCellText(cell, theme); TableColumnAlign? alignment = i < table.ColumnDefinitions.Count ? table.ColumnDefinitions[i].Alignment : null; cells.Add(new TableCellContent(cellText, alignment)); } @@ -125,12 +125,12 @@ internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment /// /// Extracts text from table cells using optimized inline processing. /// - private static string ExtractCellTextOptimized(TableCell cell, Theme theme) { + private static string ExtractCellText(TableCell cell, Theme theme) { StringBuilder textBuilder = StringBuilderPool.Rent(); foreach (Block block in cell) { if (block is ParagraphBlock paragraph && paragraph.Inline is not null) { - ExtractInlineTextOptimized(paragraph.Inline, textBuilder); + ExtractInlineText(paragraph.Inline, textBuilder); } else if (block is CodeBlock code) { textBuilder.Append(code.Lines.ToString()); @@ -145,7 +145,7 @@ private static string ExtractCellTextOptimized(TableCell cell, Theme theme) { /// /// Extracts text from inline elements optimized for table cells. /// - private static void ExtractInlineTextOptimized(ContainerInline inlines, StringBuilder builder) { + private static void ExtractInlineText(ContainerInline inlines, StringBuilder builder) { // Small optimization: use a borrowed buffer for frequently accessed literal content instead of repeated ToString allocations. foreach (Inline inline in inlines) { switch (inline) { diff --git a/src/Utilities/InlineTextExtractor.cs b/src/Utilities/InlineTextExtractor.cs index 5a682c7..e4edabb 100644 --- a/src/Utilities/InlineTextExtractor.cs +++ b/src/Utilities/InlineTextExtractor.cs @@ -28,6 +28,7 @@ public static void ExtractText(Inline inline, StringBuilder builder) { case LeafInline leaf when leaf is CodeInline code: builder.Append(code.Content); break; + default: break; } diff --git a/tests/Format-CSharp.tests.ps1 b/tests/Format-CSharp.tests.ps1 index 5ad82fd..145c18c 100644 --- a/tests/Format-CSharp.tests.ps1 +++ b/tests/Format-CSharp.tests.ps1 @@ -4,15 +4,14 @@ Describe 'Format-CSharp' { $out = $code | Format-CSharp $out | Should -Not -BeNullOrEmpty $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi - $rendered | Should -Match 'class|public class|namespace' + $rendered | Should -Match 'public class Foo' } - It 'Outputs every single line when -Lines is used' { + It 'Formats a simple C# string' { $code = 'public class Foo { }' - $lines = $code | Format-CSharp -Lines - $lines | Should -BeOfType Spectre.Console.Paragraph - $rendered = $lines | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ -EscapeAnsi } | Out-String - $rendered | Should -Match 'class|public class|namespace' + $out = $code | Format-CSharp + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'public class Foo' } It 'Formats a C# file and returns renderables' { @@ -22,7 +21,7 @@ Describe 'Format-CSharp' { $out = Get-Item $temp | Format-CSharp $out | Should -Not -BeNullOrEmpty $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi - $rendered | Should -Match 'class|public class|namespace' + $rendered | Should -Match 'public class Temp' } finally { Remove-Item -Force -ErrorAction SilentlyContinue $temp } diff --git a/tests/Format-Markdown.tests.ps1 b/tests/Format-Markdown.tests.ps1 index 878d0cb..e23883a 100644 --- a/tests/Format-Markdown.tests.ps1 +++ b/tests/Format-Markdown.tests.ps1 @@ -7,12 +7,10 @@ Describe 'Format-Markdown' { $rendered | Should -Match '# Title|Title|Some text' } - It 'Outputs every single line when -Lines is used' { + It 'Formats Markdown' { $md = "# Title\n\nSome text" - $Lines = $md | Format-Markdown -Lines - $Lines | Should -BeOfType Spectre.Console.Paragraph - $rendered = _GetSpectreRenderable -RenderableObject $Lines - $rendered = $Lines | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ } | Out-String + $out = $md | Format-Markdown + $rendered = _GetSpectreRenderable -RenderableObject $out $rendered | Should -Match '# Title|Title|Some text' } diff --git a/tests/Format-PowerShell.tests.ps1 b/tests/Format-PowerShell.tests.ps1 index 991153e..f8c6c57 100644 --- a/tests/Format-PowerShell.tests.ps1 +++ b/tests/Format-PowerShell.tests.ps1 @@ -7,11 +7,10 @@ Describe 'Format-PowerShell' { $rendered | Should -Match 'function|Write-Output' } - It 'Outputs every single line when -Lines is used' { + It 'Formats a simple PowerShell string' { $ps = 'function Test-Thing { Write-Output "hi" }' - $lines = $ps | Format-PowerShell -Lines - $lines | Should -BeOfType Spectre.Console.Paragraph - $rendered = $lines | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ -EscapeAnsi } | Out-String + $out = $ps | Format-PowerShell + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi $rendered | Should -Match 'function|Write-Output' } diff --git a/tests/Show-TextMate.tests.ps1 b/tests/Show-TextMate.tests.ps1 index d59c0c1..00f8b00 100644 --- a/tests/Show-TextMate.tests.ps1 +++ b/tests/Show-TextMate.tests.ps1 @@ -11,16 +11,15 @@ function Foo-Bar { } Describe 'Show-TextMate' { - It 'Formats a PSObject with PSChildName and returns rendered output containing the filename' { + It 'Formats a PSObject with PSChildName and returns rendered PowerShell output' { $out2 = $psowrapped | Show-TextMate $out2 | Should -Not -BeNullOrEmpty $rendered = _GetSpectreRenderable -RenderableObject $out2 -EscapeAnsi $rendered | Should -Match 'FooBar|Foo-Bar' } - It 'Outputs every single line when -Lines is used' { - $out = $psString | Show-TextMate -Lines - $out | Should -BeOfType Spectre.Console.Paragraph - $rendered = $out | ForEach-Object { _GetSpectreRenderable -RenderableObject $_ -EscapeAnsi } | Out-String + It 'Formats a simple PowerShell string' { + $out = $psString | Show-TextMate + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi $rendered | Should -Match 'function|Write-Host|Foo-Bar' } It "Can render markdown" { diff --git a/tests/test-markdown.md b/tests/test-markdown.md index 7c34fc8..ce8f30f 100644 --- a/tests/test-markdown.md +++ b/tests/test-markdown.md @@ -31,6 +31,7 @@ This is a new paragraph after a blank line. ## Lists - Unordered item 1 + - [ ] Incomplete sub-task 1 - Unordered item 2 - Nested item - Unordered item 3 @@ -39,6 +40,7 @@ This is a new paragraph after a blank line. 1. Ordered item 1 2. Ordered item 2 1. Nested ordered item + 1. three level nest list 2 3. Ordered item 3 - [x] Completed task From be3c596ca1b79b6205129fcd400abbd8940ec951 Mon Sep 17 00:00:00 2001 From: trackd Date: Sat, 7 Feb 2026 16:52:52 +0100 Subject: [PATCH 18/25] * derive all cmdlets from same base. * added sixel support from new PwshSpectreConsole classes. * added LineNumbers parameter. --- assets/git_commit.png | Bin 0 -> 53731 bytes src/Cmdlets/ShowTextMateCmdlet.cs | 175 +++--------------------------- src/Cmdlets/TextMateCmdletBase.cs | 92 +++++++++++++--- src/Core/HighlightedText.cs | 132 +++++++++++++++++++++- src/Rendering/ImageRenderer.cs | 85 ++++++++++++--- test.ps1 | 16 +++ 6 files changed, 303 insertions(+), 197 deletions(-) create mode 100644 assets/git_commit.png create mode 100644 test.ps1 diff --git a/assets/git_commit.png b/assets/git_commit.png new file mode 100644 index 0000000000000000000000000000000000000000..09afce9df55f1272edd8d9626682c8c4aa328ef3 GIT binary patch literal 53731 zcmY&G zt_TG=aX4sfXaE2JCn+JK1ONcF{yTn$1pc?L7Rl}SS3ucIXgUD^uyX%xKz?q~!~g)a zyrrLI6b7NQRlHdTUIk<}lzTnHa&H)wQdWbcqpAOM=g9)JkhXhP)l zMGORZ_2uEA{?!N30|0VgfPn<`$!7I2;D-GmoC{^>0Qm<4^*JYSNJILg0t7r`g-QVe zVnF^m>14`)d{BVFq_NQfK!FxuK<;gsg(6f=?-)TnHRz=oLubORn4SWE#d2)hqc z06=~M+P}Ab_;R0tubG))i*13|r#HOx&h*#ue$?Nk1{2=vx!i_Mw`cR;-9TBKc9FDzlJ1@+BK+94-}Cd zVns5eADBvhMF=HQ4wKIRnC$P+e1P!numb9=_<+0;X#eOMgFH)yCL@^{<4+k=e)F6(9#K;JFCF+*|MUEmN91llT{3V^hD#54}sY0kK zfp^dA2+SF#Bi50?JqBP3{0!|9XG{)gP~=^NZ5D3-!;T#+RtWRg#F;TO4n05XuVWK7 zZ!}xM$=^x~g!MQ+;ZcUZQ%nfvq2CO2BZETq95hf##*_8Gs$eTeuDP`=HoB-Gm4Y@dYI`WHjVfCH@fNlCU7HL$O1F1S<}Z|4RBJ-9*-b^frWP#Kah; zAwfgDj2wqTkLVRHJj70d93jj^-X0GuSzoL&k326nZ>vOnMpK@}B7seuG`?h;vAEuMM4JB;Zi|s=QC3G+`x$b%c1tas+Og&LpT>l&Q4!r$7aX zHBL*Yx}Zg=Rx#`k3X4l7=X4Odq;yGjVafuw1%}0oW$meR2hvswzFgX0_4&y&@zb#;7AkMFgbC;r zsT8sl94dO1@`}=m?Ftyx2h~ZHxN<#ZpBgb$X61u&sKwMu)ruU23uPQ-^K$M#VHM_o z>{R%ReKo%#z%>VD2IYl|74z%$54uJOVT`dF-D!xd;VMI20$7L0>ZjMdU8y?LzwkPv zcq_^aIE9&|nAIDV9+J)YAr8_VUbCI$piU^t(jI&xw^45>_bG8oS!CK7O?9j*Qqxj3 zDkUlvE5$Fm*=nxJa!R{JJ2gK7-~~se$aToY&Esaka!!=jl()*D%g|+cxt?n1Dy&Oz z3OYsHdTqp;ig>?r7gi8!l4Ab_46WZr^ik8G;#1Nt_M|2KI4s1qjMvBP5$%x3LWzS`=GnHi9 zODRjaWw>QVr>CY1r{ zOi~V6<|t<^h?`@Ww>t$rghxe42TszZ@zG`BjBJf{A4)ktIq2?r&Oha3HD(Q5{n@C! zX1lh$4%^I|V#%Y-)ns=1J2)o1t9UfW50jU|V7*i2D=#b`VBUN}Om9q|pkBRF*IMFM z@nU#`!S~GPn0LT;z{9@Hw5_Q>#opIC);cLaXqT%WvLozA{A2Rt`7;M7^Ctx|2Z;y} z4A2Jtc=hxq>W$^++Aw`?Si2I8&Tkh)6O0S24m|3wCbAg2jISnoIZ2;Mn6ZQpQSd!o z{tIDvY#24@3rijn7NQ=?A8Q}3fj!5V#_Be=DeSZ(@-C7pvYlnlD9>3-E5%C1S3zgR zv(DbZ(!oDMXQnx|q>-Z``CA&7la8u}&GvWC&ga!b*nZAF%z+n1I{lcgy+^^!z+*@m zdO0034OgmwOt>6J@~t2u)gSa|(t8v)(pYGZ0lWe8U-WZqbK$GPI#l>__YzdHo{5(U zU927?!^|Z#NA1Dn)S+tNRXlBxEmSs3@|9QWX*Isx zWS+9t0ebxtf&I-u))z3U&_^LY7n>_ry7C(Inmf&g@1Z$3d}tAoHK-dHX=r|{6r1ix zIAL}Zy&0RyP34tC?IyEk^JZtq+qAjb@TnDP$7%4kaBJ&La64*ES5MEP8}w<@6dRpE zEyC?Zy*zDQre)(Z)7<|8>&F6 zvP))5rXP|YjW7H)5fx{zsUJ4F)p9ltSEC^FAYTx~@GUFti%WR(yedj@%hVU*@S_Ah z-#IsKv~X;2sxjFk`^5Gl9*1WyP&+U9a9r!I(Oy-15DNsZc;y4|AL1FW@0Tv)>6%aR|a-aT#%wp>UxWxg30)ocsKa zUuU0X!m}o`WVsmMh2N7k80hrAuS*vzGjr*41!?zbNxe4SC12-#7g)6$BLgG&J>%}o zjCzl1A6NZun{IeFMLP)chu(yK6h9Vk^-s!w%RZ8@l9GNvLDC;SjhSgT{^gs5#u7@h z0Dvbc0N@`40KEPDE6)G`7X|>}+yDUJN(TTi>=N~d#Q=a&cu5gK6?eVMEvQY`4ITb( zeNoakOF7~%DU<}Ik$FiI3g<%+EV+!rDJS1>AycZt#Nf#NbSNxjQWGj9r39If;N-}i zA0O|}-k#;2nv3lhAN`ew&Dn>`C$8F)OD}!v+U{q!>L<+|kFQ#3z5B!;Yf2a}bZ8n> z4T`!*;GH|X9f#*Dr|bjVAM^iLaa)f1S~dG#B~MuP@niS@oqo3ac?Kg*ldM-TDCiRk zXhwZSP5zvXOGEH`9sKF(d1YPtKJoiL>HWUw{p_SY_q{YGs9dq~)1Icg`tOnUg$jUN z6(Gp&4T7&zafqfna*p@EVSJBv1oQ;2eGt$&fH85t`-B28ztX#oJno;bogX+4zSn*P z&rR*prgsu4-(hv{VQP34y@$+|uDck!&(65C)~+L3JHL;Oov&-RpUEHn?WY>I?kgKR zC;hikzfbX>isx3pkJehhuM7Q;^_8A;)!KW;vt{n>yV!4E{;wnb?~+vB>oT{Vhn&pC zb^fmfevjdg?H4}%5P^aOzvf2x{Vjz2j}@1?*RLDDUa_X~W@mcQk8RZwuA8#QJ>DCS zS+AXs?RNo;??n~c<(HnPYCBowpU>H!OQf7fB|8GKYmtnevfuo&S((fkoO{St4W{6k zfJn&{w93zm-tS6EL*VpoGofR9GHrBe^ntAHN3x&g9GL+bd77qmU31B2B^M=H{_bm++N@>CY4)x?IMs*R z@AF!{uXFX@`;^)$(JOgsu32C>zh%6a>KC}%g^e|OsUuP5y!&5U4@7**f|7?Fe?#aV zp&~x|C^#RUE-J7Nssx06$4cdrM`a0%*{!X&v$R7=P^gESEzRStI86`VL7&XmvDCD# zAJ=wjALy<=pY(rRWUreP5S~4FsR6xIN7!Azw;;Y6+$x<(oH+(q93;h^ z^?iFkwr)~eyG(mEdxe?^-VzAfPetiH^FB7q*!L-?g}7$~Q20ee)lnl61Do{u=*KMVSEz1BF4npX?_vCgQ zy>a-zDI~K{l8Xa5dzv`j?ZTAxUqkdE{rO&;_*Z)W>ls|S^9RDuEkf@Dckjaj-_?Te zE`%?kKG3hP@yA$9CKd0bb(|R6D%1Ao;;cV@+zsQ=v&$Nk>K zJMJj~lVB=Z+tDfCxsY>h^2jX7ck{q^bD!4Rm7$PyX@ z_x)J5b4bSC{?F#T%cXVC{~7s3`NyZ%9*q8~S`5N#J;6tPl+NL=om;q_+nAl#7&cwz zXK3}dfj-d7>!MOx0>9+q+ZlT$1H4Ik{=IF}d($Rc*T$@`Vg1ixyYFK=-#HAQIhR^5 z-3^ZAhTY3DzxT2w78oap$Ty|UZ0m*V>=T}oR`1EyKL_$F{q#xmdFs9IOaIvk9?t83 zZP4-kJn-+}el6R5sY$rCPwHN4t#qKXE%5j*7U3^RLa6pLDva!XzsCJ=1TQVV`0Tvo z*dOwabIB-nS|@wYMd>|a<=tWJJn-%a3ouU4_;yeD)KVSkLx+Z=Ue2 z$eXE8YdqZg`E^;qEq2@{S-Z@*)Hta&kMe)tjao;?@=G;dsrTFvOt_oEO?Y3~b6yG9x%s}n$qan9S>WQ|!`dsiv~vBPx%rIc zes*KBYcp@%mJ3aF-Mt;pF9Gz393$r3W!-F+!C4;VXt*V%bAZDAGvO||F6<^y=GR!cb1KFTjblMIxG<@6%%sfqb6)#h z0j)(L*Z+Jy^YRb=)Q|qz$BqPX*)aoNPAwhI%k^X8t@Ucl!yAHo9|+U5&dy^gd$lg} z{WL@K)cLh8N^;jywXKUYMLGFD&zGhjZ#|GmIF@h;y&JmZe3)Vm(=$b1&$#%$`T4%- z6c%H9102o%!|E`bu7{kP-@>kffn)^j?z{%%l-bDzb$nIUEP%Thq(80 zvS&)i^O!j=gU|ad&9B`C2SrPlNt)8*e0GX)lN1i&`GGOcV=9)bx7I8UQHrkXFf<`b z1CO#4qe84ozm>rV6H9(oFEEj6=eeb|`?q78hP$~2h#jG!4 z%KFRrp2|nwu~*tgntZTv__y}p-EQew*}v}sF}m`h-Z=9fIIlndL5C8r&`XZy`iTac z@o;QU(acTd8W`z|Xw88dXGB)18DBcsSg39NACaHclRPdFq8ObU&y|log6}<{aZhxb z4lLg!O0)O!wUa&K4-Si#*%P@FQaj&=tWKw&kDZ@3MJtcHQA&lb!y3hgIA4pO`Lv#7 zLZeDE1e&g*au%E->Kh?;ZP&b#j6#|%*NdHx?wyY!{`aCHhFlma{1C;}P6QLgMsuxM z*pH6dj}Bk{)Y00n_1c)OO+TaJn{)K7#rnXOo`1s()L$apWc4}vEP&7FYhc^@3gFG?og&WeK_T- zKVt}h4AP_(L2v(O;;lBGxE0+8aft)E81nhNqYO?_#s>;X2#B>Kz66lq$DflG3m2n& zgDN@tot>dp*L`Z8tx^BMMYii%guJ(r!n{0EdCk8R1tET-b~?u~YU80Z6`H$HKys6b zh=&Zsvl@E)vgQC{1xr3wN}dA^{D{KOxZv=*iXBAieybl!NDL(c z>A(DE+ZntN(+&=sNZSjS+!9}vx|iU2{P*GFg;|2@l*a;&r_zDFsngGD(${NIaWd5& z={V%*_lJBk`+Ym(TU!E(stl#jw8xAS{=-@BOBcC5G{1x5A=z2r`RLF-(V@EAJ4D9~ zS*6=!qC)Kt)oZ_Omx<1VApr(~ujn1l3kujDKfm)bUn+U9e2C;|s4-|xMM$rP=SBsk z^C~=_qH>F>aTHgDB&vJXR}|lan2Z3E{IdG|nJJ)qqI*`clovfO0BUPmfB*cA@c-oJJ7o;IKoUIx?lH z=GVXEbTpO=#p4$vbB6EGi4q5ygrN8N z$?;oUY!kMJ-#`DlZ)0P33fZUNK!B0IY~1XkU*5Gyyi++<6{WbIsniKY1%~K}vrK>w z6Kgo%Lp1~zqj918mC3k2>GJB7!2`G?Ra z|8$!-7wPYD6ctbP8Ilwn8qOmT_z2Bj8qSh&lWdx9B_fJ!Tb?bL(%?H*oX91ruei`9 z+HM8$!r3GqST!O)l1X1H6sKmaJrJu_Ct`KpLQ$hYy(D)pL=UHIw=sdLTis2p>t?jF zH}#sMU*pZmwl+4P7WEdjX8)STH&3Rir8D+O&lcbvZf#o4Obn}rw>$+TsJxB`X!WAG`D z1n;Q?o%Ks_;uD9VAD+g>cfcQ>o|rgOxLq{`MTaL(-N!JUS`xrH*$1|+Xi^lk_sHv^N($NqoWw^{VFp$`+5?1recgDJxZB=W%R&%!o>lgFA8mzRPN*_)Q32Gyh_ljd=uN#3BSWLFBHdkP5fo3e=-w}!i# zTK=2nB?FU>NH)reH%ikSxJ^;`3)-Jcw-ewtzR=NYQP_i0+Ak{3JDOI;R0doULoN1q zbI%HB33sR6M?Li0Gtr`>EkwSIpsvWT8p^G-ot`oHgIFCy@W1wNY92v6>^k65b>MsW zl;YVV3w!L|)Od*?Gn(s=A!WaihHa;WK}|9Qe`m8m;V#S^{+-xzx$mP*Qh~Q;YdGuo zD5)BO@cmHqVqkKAiUh29wb2X4IBTrUop_hJJz*IQAjXqsB8!Ru=nD_O& z7?ou{4t-2MJc@b3XOA=)FV204xXRv8DPCPIyx&RnL&OVkrNO88@;Sf(SPnZ3U;pw= zQ)%0@9Y#JBw4aR{sPu?|lfA2+TjKLk1tf5BR_n~(=b)-d1h6)NKfPW+;-X59Oc(x0 zRfEKO{45u-L$J#OaC~eoLAed_lkdwj&D#XQ%o7an$Ow(R#)fr)amca(h=h zvH5^nn87n3;GyhjZP=-w5XOugKbClUYEBYqCLwVe+~ zv&17|&FB8 z>HArS?A|`kPfhF3ws$K95)4PA6=KvmTJ9mw!wJv1Pa&eDQ{G zJ%tGMitJ2Ka8{FD@)bHyrAt1Sm{Ps#&|)plgt03@E%Aw--U$wo`ZNIsr>^Zf7R+Dy zBg%IB%02*#jn`3R zf$Pc&#_)>zKiwFAy^3#CZ`Y>{{zNfhKrEvr$--n=^1+ZMz;1*f^{EjC;{gdb z9a0LXoYhcb#vnAHXt04Zl@HJ|vkO|?FKR!hNA1NxJq6LbBl2q!NkHIvM}0KpVG-F! zbk769j8MgB3EbO1PGPb7=g<6`K-D`lUrsdD~lW&I0HSrdQ- zk?BDL7vP+D6B1l=tO#W9Mb#7w$8?L*%?r}s^|-F0Wpu0}cW#reMmakECJC6M-7IB1 zPUkNM!rn2LIzZ2bquHuYriRL<>fr>P83#S3JD+r`OpZl?$c{QPfCHI|!SLWe3TEf?E>zo+RSIYl4Q&xJ3jRX{VEIg+lm7Ts}`9sOQ~~ zt*Y_EiedLlc#eVzb<5X;!Zk)~Hee3dU7tDGp>cZg;R9mH8M3r>pQK4feKa$2`Bf?k zwHxgQ*}Y}dJAeb6R`ZLd1mt^6{=LXpk`mk+5l`XWX+4P~ik188ak;gJwNIrw1T?|k zMtm1M1wyn8trDJ8m(_L`=uej1I`+2436j>Qnr@=tTUyU-NCC3*(JvYkY$Cf3@S#)- ze{O+M1QirH^3EZ^m#M05iND1yD^|W~wGtc7tnVQ1Uaf|4e2TuwQ@#+FU(EIymIxRk=Z+ zQZ7;4hRcDq7uWUYcmz~rxIO}|4c6@KJMi!dKV0+2{KKJ0;WQ9IL6nfc%#x#rxF`?} zeNwGmW^uOW42uT^SA2Zz39@APAe9culVj=-)4+d z&HQ~Clh|R~)tOq|yEGDBw)$>=3r-%&zHXdh!0Z42*EjU=BJMri_F4HtUNbmp!c z=iif|fFkn5nlG6LGGhqr-yB@cTW;#HZIt8?B zP++1GK*mYS-%_Jsi6@0#tIq0#^m}?)iL@>R_4|tI*dR$gIOQUwYj~h2=ake10eii1 z3G34Morfoj{|x$9%K>a(Ve1E-5JITH!L>S7!=thfd*^$b-@rCSgnqM1Xmwj=T{NqM zKhpYV_)(K#FDvU8Hoi!K=d4sqlD11nB*ou?zY9FiQ0Q4@=fV2e?~g?D`Z6^@#NyU- zksZoX;MP-LK%_|6=c?RKqT?&F0)3y_g}F*7(4ZK<4CJI6p7ux^{TavR$ia{6v}_wv ztBP?H9x8d;f3P+A3T>rWat(*NYOb?*HT&$#++a;aIPj#_3o69qaW(C%v^dX06p$}N z(`3-4=WEL2JYZjtz9(^DNQEWc{`OBZkX;WcQo`ygX!PSbNu;|1-Zx~!J`}BC$(YO= zS22tefvr^H^*}jpxzDP_90|2(AvQ=-8{w(y5RODWTC7tGx*&>{Nj9r+M~%H36_|eO zMv}&-%dPbIn5I#LJWv}$rowTbt>*y#=F31zjYMwQ&_B0OO3jP9LLg7+RvTF>eBoD@ z*?l`JhcRLeZ*&+3hIoZcu8P&+r;nV!_w{%kfe@FCHgM5;6&0I9@(tExg?a~R2Ig$O zpKAH4gi&X~(VK0E^I6lV$*qRz4UzK0$q$psd4iSpr~%b-uqJVERWT?Cj#q6{PNJu* z`6rI!qKRtLha`THr7L9g^H#1;)aF;NmIsoBByN`gYxLKyomQRnaD=fprnZ0l*=4V-_Qa#wWaz%v>EAzSZgdB zX*J6?Zz?2nC@{%oEn5&KyU906L>9v~Xf3!YdKj*_nA$BU1lECPc}b)%Q7O+V7Bx7b zHqsT08N(GTj`Pj$+C+9nbBo4_%)l|7&kvv$Bylrho61-e@9(RY4oR$G)LG>!MV8U| z4#vK~4I3@21;c>b~a!Bg+$DKj-^%$p9pS(g2C-G4A|TQ`mhB>4@%Tf z#Mo-na=oeCc2P9OhH`;Ne%nH`AEE z(#%e1?@%Yi|C>|w>zCd;BB-D?HNl;+lE!GWXfxMk@Z6Uu8w*VM?#KE(9T8nQhhZS9%&fcwBDcknI7EaOD!JOLCp8oERXt(YecmH zsMHJ?;LPVc-i8`ulD@y`nB}32LoZLwxB^X|Ug`@;dNG$cUEn!deFF&B;>)Ou*Ks1W z8znUX*o_Lve+|f?U`iYS9jMx5K5Ia&td5rC+-#aqbgv}Jjj*F@dzOCZF?_Rll?kw? zM)q&;eS0^a2t{)Ufg%g2;zw0_av>3WvpfO{UilyE^2EN-+zS~AR{T@gF_M19e~0!D zpDgfoGo)PpDgk4l7-9&luQXFEbLXHi2Cj0xi zKsN zp$xi|ii-tjCSD5>16a6XPN8GHB6hnPaQ5H?mnQVfR!y9sal-G!r!2nGgI4vb^i_rr zDiyq#GhdmZmS9MAE3wpb?E{Y>@OSXy*)cW3=RwlMmQ|RS-}2dZWaPm{ZD5h+2kYkn z>73t9r)p9{Z=aeJ<@goDB9p^1seZ42(yO?kp&HT$G1UTSILHvO+)}U0$`KZ2nh=m& z#`S6LrwG|RRwF6xQC{XoxqE0F>SIZX+*e{iJeoxVL+btO!F%vtMBYMT?os5-P3s^q z5ar>L>vMUHvPveJb{=QyI$kMhXm!Sf&R=nwi)^bF=)^%sVzf5UnMnptwE7$xrzoJe7Q3I5nlN-?<-#HsBh0%Ru22NV@y`*9h-dse^>4 z1Q6XJ>zkuQksF(j8d;AdgX#5W@Pg=24f7lIfym!MGfnSjnLeEQiXNBg4{}x%IsV1P zCL=7vAm)=<$v0_|U!RpGp8Qxj&62BEQ0+tbvxOVLj6vFs{A-nBs-?DfdByMMs>jWA zb(|=yn*Z9RG`U?AvQUa%ug?=Cf%+}y6``aL6*!hR`;WB(1m9%7QK`^`#_xSG<7iqh zk^1z07VTo|PT;r!nif>T(^(g8&}2|75XzyBUHU0NmumZZaV$M+B@Jd`PCqlP#TvNa zcAINnLxF?;{Gbqx`vwSN!SCUFYdHL;YGf9B{(GVXph{LQz9wC)bKl(rFrdZKi1CG4 zXOJg}zk~UNFv*N@)mY(n`xQI9Yz#Dkf8j@+*V2x} zVDdCz1kx)9wWVEm5ifPDe1_>gkgXm+cRPuO6*I#I;7W37FkEjEs++&79~KfjIqi80 z3VAs}WaTd-cRDADI#pPp!{k@+rZ7vYz&o&HE}C7t{$?o))2=dxjKl-ajJJs7yH4_s z(fS#G>e!@svlIuu__X7Pl-j<)!{O83QW29l#7*4 zOHH%Q?-NwGN2hNCOaAN=7sKlpIt>B-PrZ@tjmq(_jkjU9(8Qac*+y1bX-$f{|G2tw z*?^$@L*4I3Iv9EnNBO9u3pq+?2CjPT8kU1>C1;J7dI-UB?ygh#QE*bqUJtW+9VexO zi&4gyxG^9{Spvu6v&FStjo{~|kfo^fZHpt(^(q)q{Wa2KHbZ-wxGC4boW#l)AGK4+ zEY{X6+nJ(yGueI9@ysDo8`Gfd8Iw~3T#3C9s&nCvMqy-90r6T`7*u_!Q^S$!QJRQEEk zBGs1ujl0{T7Q1ATz{;=9B|7gc82+cVJXlaZZ~jp9Dgx{BY?AZ9Ug_HL!fBnhnU&1B z7;~tqJ-hy;!7{B5EFre({>cDh$Q6QIVJodc0W20_bS+m#{4;;j)2agI+69sx zVfi>hMjAnDv1hz#>Vr70wK9#J-JMeI37vB&97{XHzLWZ0*>SK;W)5Grdg%krcbxEE zC~^{A^g-_0gDDZb%2ES7NixcB2jlEa|GDhJg}+R8H~{l=a_Yo^tI|&(Z{MC@jMC+R zcPACxvdV2OQ)5_D?@U%68bu&2sK3V!^SV||2RKQkzbSDj6eI_(7}EiC3Q(jvqv5xc z zE~|ivE>_y^Yti8D-O6I)2L!w|2&r{Rk#&2E?0651d-14) zl;&WZb8ls#(fzSw^>hoceYJspQq)c2{81Rjg?~{7K{SU*Xp>~d5Wyb(Br5tC7Xojw z(K3LXe@$-RFwH2zSSL_JXn(R3=&aJBr4Koc~ z8X)xvTNO+q=~vm10itxUgYOScnS$F|CZDIoSHSAe-5w4O>ei^R(L@i=j&v0HCo2Q@ zR3}+P`2o#)*KJ?U@TiX|zy01mT3C(@zy0_NaSi@MGJk+%B8y+q2MR;F*a;KYdZ{iX zDKOAQxHTOuZ0FXuFv|EV6FTA%ni zxg2AHYq2i;IUS3{rR*tD9bG-6!yOdk_4w+7pGLgEJuXAO&|Ya&ri(bRp?tG~nK zz~W2x9+K1AC#30K!00Jm9ksSL9S9~M-gE}0a)0&M>Cj56HHV4OUTmJpBun5=6x?&5 zq?0ejQ+|ypJ7o_Z7m|PnK-6*_Oh~c59I+g*;ch+?hz;_de-s+;RuIjy8j8DL*Igdq~_A@t@E_ z{Fod$C~+aC|V84}pdM6PD*p+uynccc{s|`huhrj0H!5*xN|| zQgEDv?WRhn_zcwgMcMS9?ly}WG}vvtCbV7J^^Vg$@%3Upi*zuapBgZiQ_kJN+eh8^ zCyz#EMF!q0a=TF>F%TgrUSP3+Fq?B7sww3RZ4HIoh!r}9qFG2pS`Y>*MG_y6n%{CX z&>=pY2Qt*3Ly%k3F)4apXlbd~lP1QHE8AWdXpY5N3d639BBJU!`mnm(Xh5;Goj+I! z>|+RCecGf+Myt+S4m9Idtp{VXv+l+)SxUrZ)Q7;Dzi+mb)e%%;9<|-Y)5Ifi)JyOC zwm`#0d15%;fYF46a>JNC3=M)b35z|Xd?M!*!qP07a7l2UwWRgcE!o*1h@UY~ZZswMcZRQao%6(U6j3UTNa?dZ6TDOGM&i~z)#h4A@!A8F|7 zZMPvC%9?2lxe+!YS%8B44RR_27JerqxsD0LVtS&uY{6LXYiIF8|BI&nI!SAL`#d`6 zfk2lMhX$t)NIx~Ru^7nTqRT{S8wnZal0KSb8Qbs_!JKjF7N*HQ!&3G8Xw?WnC5dRd z(CTQ3xheJ;Nwb+E+!C;W>!d&NrinxB@!Y)}A130amj;vW`b~RvDaBbouJ(GGkt3NL zN|l)W$`dkY74oKLp)fdugasmYSyLD$ zCP_^1g3Bi+qEZ6RDa8&l39mju+(63FTy0uc>G+C13c!aZy9Fezm?8^H=d}67+K_)= zlsi<=-E)n}$8-cG*=n3Ag#<(#`4HAkZ10t)1tvgG&ora2I>xRCr>2sQ*A8=3`${5^ zY;7B3xLw9<@~c&R3F$RmJA+oMvKL9VvA%Vaco*)H zpDsW1w<6;b%hGaXe~6twQPDZ11lC8wv-cAomI!Gw(DyRGB?f^|f3-}yB=hXKaHTB& zKJ$%9p=?tojfD9F6k-UCi-4%i2PzO&rOf#WP}?tOC%8{b^#V#DGkQ#krqxUSWy`e= z5+0g(ihH0j+bQ=UDQ{#Sbw8dc{fr=meDY;EI~h)Xq&0sLs$b)ijC*^hGPwgE>*m&X zvS!Snq_*!pTx73M=y!tTroPXJuO=@=+5MjBk$e-(hrMD)YULb5e|Uac`DJv+q36f= z#XYa2yQ<&T;9%lVo`onh)cVNRMwrHnbjD(!P8H+X`7t0Nb9-C`8YLEtL&Nc;5jXLl zI<>#%gyo~ckx?4U)g3+;NgEvu_yL)8c8)o*RW%Nv89h=XB+XpR&HiCysjuq3~9bOfBV-eaDTQl*2w%jP+M9tS+dQjVPQw)wwA{kG(piDpF7UXM? z)h$^@mYI@*^EOdqhHyRi0Mll7m*}vZWa}(W9ooTUXz7?70a!LfiXten(X}#|wZ)}= z0783wy&z>67HAwLdFJ}?a5y(bGv_z>#mw&J!-ZTkG&9(2z6k^&9k;vKbc4Wp)XWEolz4MjgYF3 zMC8#rRYuJ&_9MbUPysQ+jHb#*jw@%JC4ea=czCR#mSp>TNL_9F ztP3V|pE@YffrKTsf0R&8?ujY2E|-h=87Qi;BXYDJY|SIFY~IJu$gKW1c3v(Xrpd`; z+FrdlQY!TovA=&sQ>6Wn%PXtw975ZzL?SLVfFg3&wlzkz8~&uo$MfAHZ%4IRtq0(70a{?9vnKfxFC?Q z11=5cEnSPdNtwtq%B<@KvzBP}*Y0b>6L}?y&hW7nY z2BODchIa&Xq$=6C4jPy-{n*BqNPU)fRRXd|zm0jo9wm}?(OrxzRlTwQfcb5UhKQ5E z)=qHkanTl&{na`7l?gy*ZH}Mtem})Z^5zba$#l<>Na)uOiN{p8VRS{R>>TL7bSR6B z8I}^Sj29ZyJ=-QA*5J1w0+>(*o%!-vtxJx@-?#v|VwvCOKBu3Fr8^OZx|FD&W;B9V zir9r~-|e;acG#gNE^d`(>8&yUM&mS_qyP=t-3hbn6?wDBE+IRQHnD8Mm?hI*==i9nK?|lLD zbF@>-H|Kr4dZ`JgY}NrXLB*)!C|xl-IB(Q>iJ4w1Xk*noVZS~N_#;LEN7c{K5-T0) z5(Q$4Oa!KF;9M1BK7g)&zU1ffN&?XKyb23(r8uBfF4^t!7}%%PhT^Z6{*lZw&V_A zcPx$YE14do3yf$)0_R;z2)p}Lx5ydzMSg{VEmjnN3o9aauE+V?>~rr4np`^I;OaSr zDIJ@Pr5|k_vNJc~YVz?n7)SdUxxG zm?Xl^%h5~9)xOKkn+f2)K0}qyZe>)W>Ju@0vLn43&&ORyZzadEu_z6Ey7MNzmTmSl z&<#oN$fYL%WZBF?gaEiR_3v?}D_;i&+_ex`6L%)TOKJh8oL?-G@buUIWPi^LByj)W zNi$>K%Ohsq!r(vR0Yv{zfZ}cB#phSP&x~~{*Z*B=!XZdmmwyPwWiQ=|@o(h`hZrgc zhg8p=k2!%wXY&6t8k_%z(Ks=EA#&(F)1RG@Yx&Z-f-1)W2cf9kX{X>QET8*E?$qZqBu# zPO<$(mNiff#owR~f~^p+U_ESs1N&wjyI$YSy!B*gd8aBNZ)7fhL6E?)wyx z%x@mvv9~`C(tT|sbic&kY~OOcpz4L)5ta{>@NoJEVF-Cn&W2_=Bx+LNhI)2oew{m} zb?W$PrDd@0*-}|-dLfbK7U&pGVg4PFC2|55dk{YZV*4BK6`zd$3HpW%roo#ikXew9 z^_JbQJAy~16;Qdue7 z5J*kOKH0a2a|rc<=`ehMZc~@0J~+rapm{uwoWksiBozCZ6mk^Dg9)1DP;w??PM-4< zMCL;2efL`m(V3YqJu>P{>7MHfl5V$dt{=nVHVyToZbxwgLJAm?&W;DV@tzakiG1_H zJ?P7o;)!$G18leQxgEU&g!GI0o+iT8Q48?GwX#X%5(ks(+=a#xqbKR!&&Uwb22p5eb}Ei`qn@a@tc0%#J>63J}9irXwU`L-bq5Ht|AG(eyKs7;6|^*L`8Q_7&B#flO28b^%*3VwMA7s8V!0&HHa`$Ce)BKGDS;&DHrOr_ z6(t*q2iSxC6}ZquMOmPbF3$s667QKDMWUxt5d4bTK1 zr1*15%4P5{K2dvLc5?ivWe-CYNF zcemiKAMd^QtD2u(-PKh+`<#8&TF++m6S|k58yO)+w`qIu;){ zEYk6xAXR*B7*WhAI=WZOOv5K4!7k1ADu(Uy{`%w}=GsV`m?!B|s@nfMNH^_=)$Upx z2oODbL3s0n^pSfuwUCmZUT1}IV*FbQc_{k^V22Snk;|h+=g4K0DvA zW(Ug*g>sQO)*bb|5d!GHD-{{!~JC;VH0;Eh?Lo6ZR|LLAMou(A5-H8i6g}v>EN4jJ1U~4Q+?x zf{=?HQ!ONQto)Z}dC|z$!t{!B6SES+bm1=7(rT4F`UG>KY5d}H!80~DO8Gt>YimC$4b%#=gijK=?i}l2dc)_DW(O|7VeA*}nkI$&-wo&jWj)!fZGa<^Mzvx`{ue<}n_ciDj`bXj9w%dZ09 zi`c5hFVyqozPEg<(o?KT8;dO4f(db=;2di<8OYDMIa&P>R-xN0SQ_ z7)MocTvYa1W2}#d-nh8A$-z;>@WknxH0zB2-^_)bY3VBI&WxWjyP1^uLy98KU8Yz<-t3D{R zy8Q6pBmoLeo;aWk3n)!x1E^FifP@Jj``|}19eyLOO63T+%48OY4)c zF!7gQRir#}gDs@a-`?V~*fjn_0yJ44$%@{bymf_P?7ii(;y;QGv=76sTW{T7=xr+7gE>j_jK1x1itGqu{US zt9J~<1~Bi=?(S%fu`jBgsqA9!a~ZO&^?xnmQO;#KM$nI=%R{#e*m0~i&goHcygdjC>O%*XEr}b?fL(Z;J9r z2cATtsabx-eoLY<6+=mNMxm93659+vaKvB_jn)eaLE-@OsSzOcaJp}a!iX`OnBl&NZ zXdNc_CPqL_)sO`=b-}dgXg`WBymlYq)N)%|cBk(F%WzF{;`36LJEfH^h)|HGr8vGIj()Pq^>JKDteck6Yi9V7vyEG=k*n}TbK)i zMLGZM^@-u=k%Bcc;3`UN_`W4-stZdd{>`dY_liu#ArI~< zj(Led(ZgEwALKi#ruG)UhU}=}v|Fl+wGT#8`|^#0`(25Dl!56)=KVML-43+`E6?qZ zW-3wf5qUnEm<;>CB;l-H`Kz9abq(znVcX~mt|bBkz54gGp!r(<{&kW}-vF^WKg^${ zzKkl}?LmHDxKUZ*nOE^JyQjhuf%o%sVI$-hOVkQv&yJtZJFlz!3|y|^>ZH2=7g%2s zgIr~w7#-}QR$dnS{n>293!e^Eew-=mVx{Ob|8E{WF%1e{9ME3$0W>Yh^39bY8(9#2 z6wUU{ZSkF!2sBXHBB%d!Lr*l{b5(UjZ)8#0(IzE_p8V&A4d$u4dqqFg862>MjW$8qA!l4ioabl0-?)RUsWwzmp4TpTbGKS69sUdzg#Ly1d0iUI-De@9H%23~Jo5}t zWSMw#QxrJ8j@3gGLatE3rT)0@mU4G-A?59UszG}(eEPCLCD1v<*f)f48dk?P2wMnUSBk6^K5#{vT(`sEHaNPI*|Yt z~BGw)_H=z?_YmPnJY* zdv%gXs8ELX67&YJUKsF1uu#opQEV{Tv-H3m{q|XLYAi{Pf4cy;d2=Y=SF)#_VJC2h zkTt9YNOU~+iBb}lbBo7=k_4F5X78ifiba*- zU~)P{URE8>g@?!`VSMTzM7wyM^h3|dZ9_(4Go%1U<7p~8tkPPwh=Y{F67j0>8V-5_ z^czFaN3ZQgPqdTLVZ^6o+Z{DqyPO#d4i7Vf@bY&aQZ`{+d|DdFTsN=Vz~)IR7sUlS z>wlN8^Otu9#Qta$%yJM#u4>XPTc8#ci#xv-VM5ZYp~@6XtAg34;h+l_XQqz)X2>~( z_!lHL<}B@`kXpZ5qD%g52I)`8Lpm4o{`!@iDWu)sG)*JB4eCwVf(msE3P8{{GQ>CU z7qEf3u?iS=qQ*a&nUlfIOXQgW%!yL;)(s5kHJRTtNAf zqif|T=Dm}gyeK-HO{X`8NI}xf2%)Q4U{sF#BnT*dVZIh0`=hKn48)wYR;L}%j&%$LBh-EyhUI3Zs7>YPsNuM!p4%REM zn1N!Y&y+?{Kvf*NqAkkH-rw80o@1zib!G30~{5?-q5o_M3UD=Gzr*r?3RQGU$) zlj06rfmjKR)8T98c=)1QTIyVf7Wn6#u9DcEx?=E_3AEQfgBN%fapL0qJ}CjXF8$V8 z%m0f#Wt;-u_SqAt^rt_%bSqDaw{YbT2&D(3WU9m;N-r-3$fZbDLy}3=Hp$&!B}|FL zK9wNIFBYU9`9an_QH3cDX_`>>z{t;swSN+nrPrl~3_~-^BuFQN@1YUqk>&y`VG{9Ih&$}?O5HR~ zkdFwYRMb}aB*!X94ZF}To#d0CcBfO%-5dzT9ri|UAp~Qr8x8_wD@Zk97}Hn>%ac!< z2U|lgghVS^UdMq_k_D&b?p>V6%*!AuO-u{86~U0`ti>jtfkGr<-V8JoZDjUTz82TU z-^sSTZLBQDUa8>>txOqD#w9+PP09=F0Ce%^jOB%P6m%YHr`}reMDaG_KLyt}H0(7V z0nB23^pfajD&c6qnoo+t-Im!g6_8a>!X1h$gk{RE@h@UNxAzy*3SIBQ0+Wt4}6 zVR`05Jj`DDx8S=$(yiYb1y5qwD?Ih% z)ClsL;^FPA^!yEVqX>tc#n&>?;``?0K;2>-v$@Q9_;aNpa&0?3u762(@N{2(Q&_Td ztTQlqfgdZCkZpTUTQ(cg0ppqNSm|FGRE-WGs&IyRh^_GdX_D-u%~{Q!!FwIoORWKI zYxd(E=3@nFO^vJP5aC-7pR7Nfs8;aynuHCvXS)x4`cp`pT1mpRLc!0t^ONHQyEvy&boT2-h0&fBv7gYP4) zDRBdFFh>^W#!;nXeqgZ}<%?T!FL|q6+l;{ndM<`sNs1{ZER8tqWV?)QgN z+nU28<}Zy3kcyhw*4FOgDO)QefykF8=eA2l4lZF+7?FY?pGAw%X@$<(Ycq8U_@A;8 z;qjQB!kx?OvZ6jwOBg-7OJd(uIDO=66(zvbqX@1=T42XYMXwq5UY`T_j8IV0k2F5b zU6ZVcsHvgW_47xYSZEBehqb2KDuY5M7Y=xxKeQP7N57#Z{1V~4oeLrigtZzAnA^QI zEg)?m5==r~vb9|6{b7(qbtHz*a~^e+OV2Hq?>nQZFYD+bx9!e;3%6rrZg<=7RLnl5k9-Hb zwOmitcgPq&y)CJdi||4ql;Gxuqk4n)sL6aKfPE&k|SNz-`f-E<*CM%h*h^{Fn*8Wqy$rGmuzC zS$Pkd&%uR4-|PDlh3t!3YHL#RSNfJUYKi+4%mFA`4bZPdCSEuXNokCGI&7F3vys+> z=1K<+b97IWkTusaV1Cgc1={i&8l)-cupMo%W^+Z=Ii?wpAakNL9p-)Sn4pLT<#^yX&xo`MrW{x^rN;fx7_&Mf6Hja=2;@iDx-@utb22dQSa~PliGU^hdCe4eHyr zaj(L)dAZ0QHC!~u*8)$dp61JdgV#JSWb+5^+LObx!8F!gKg7?>&;j{cB%1Ze-XbTe zO}VglbIE2j++jh{Uy;Emfse(U58^|Sp{LFh-?a*OH23I4^mxmLI^-JLDSeEu9M=CK zrz*s-I2wmnzd;bd!dzu5)(k(f40JGc@@b3uT5zUlMMf(or-4dP2*rxL%BhI3K~Q6V4q&8u93sf@%o8g4K;;r7eaEFj#KBl8*BS+G;P27lS+B`t zC17uA#*Nca|6H;QC{kPkzIN2ct69+AexGD zp!Jaq*OCyRFa{KK&brmK4N?hKaX)5gsDI0|Nk}*L#)t(gS34ji=f6;$pCQWNR!MKO zcy>S#YX&>ku^v{2a_vKQv>L+D_t4v^l|q3qd!gzv>f5UzPN56-F5bse(6%$y#!K$o~IVy(BwC`z08x-@27%o+}O|YEbQy!$U3+BiW z2cKX+4hx9lXEO``7(c1AW!bjJfX90gjxZ&oKUO+&t`poigDNrFDmi~q59jajn6!cH zZ9c=9-+3Y)%4^P``q7eq=>;r;|2trXkrTB(v5Y80Ly=T{lzbjI`?@ilO)T2}(LCWe z$t~LKNAr)0Zb(8P2XNI@+B=1!?c!RpC1Lo`r!c>Hvzm&83>yaty8~&c4|R5FMKew! zkdP(tM$2Y!w>xj#cmsLOOh-z(-oxVvnI9Dl868!Mp zt_@lM1knDg%xwP3YJf4NloJnJbjA)LXe>&{@Y5%XK6kpu9S7|R2u+4+5kaq}#wH=} zanFcs9FO(0XY6+7TsoCbiHSPdj9Il_(mBZY<)IA?hKvkHprsn*VGX%WC_axxbG84| zl&j~iRoP0wiSRJb*Dm8Y2>UOAaX`H~8-v?#5-dhif6X)0VgtUqAN7^)YcQL9I+grW zEwzbusUiwLkYUl31En}phqD4wD6~vEEC|~p;)5-~cH82o8YE@3W4adE^r6YcSUTE) z0--9;37+EDJXsBLvrOKNZtxp{qIXR1P{cG^UVNGuYnz!>ayy17RhDjom^)m`Wa*(G z9509;QDGQ_xCwFSqLv-7XMRhPA4s%;0~_aceRbPR5zO~hVmN_=K}p+% zq9!Sr*^wZ8$@trl4Fq||K?Tch#lnO!J&>rWm?7izTEF$_o;yR`e|Qf)90*0>Bu#FU zZhMOter?(lA*SA$V5z;+z3ek9c9`m<>Re$@+A6s*Q$F?36ZUfYLCRO=SIS{ClnzQ2 zKGZryZV#64+z@~jT(BQKI8XyHBNz2+qy#fz*6K41O&Vp~2J-EefE%=n)n5@$?M^5{ zG+}hXP@3_g*)NdVeEZ$GFuvC?e$0fFWx4?nGf@FO!LG)e=;Q4G&Ea}Te)cWhbGP~@ zLq;HRf(PM~6kFj{(AIjmBQr@D!dICs^1G+=A#5qpPSEwK*kR`ut~7srn>RtHPo~T= zNGGQPu!6*Ee>#X^gwSXUz7|5EBCfPzhPcsz+DK`2Irve^jPcj!nwyx?Ovylo(&_{< zSmbKRo_K@mqs?;T?xN}JMJ~>0V-kR)S!MfUVK}5d8?NwNc<}zfvp50cb{TW^lPzWA z3qRO_?_z+xE#0=6;X2WyQc%t1%X-fJEE=4NjlqjD;3tz%`=(;b_2u7SO+hcZ&`yli zn^s~8AJ1B5md|5NN?2oTUkdVjNu$N=vE;)3AR}^y7^1a6*S)oTtvn?j(A}jvcNjTa z3yWGt)D-mQNkovWTc z?)@_SccPkXPp+2wxD<4@!@uh@?1ESPElOtRYoEa5*Bi|PlQq`qlm%R*vuLORED3^g zQ_ul$(h!)rO(3ovQk`XNhAOedUi8hmH}x)=_&|74+G2JXlUp?g>c`GX*saA0N{pOy z!&54s(a887Bi+(|$w_?W2>_@_tm!ug>oSp<9s1c03;!lRi0-W2<8-z-fal_4wMY-^l;= zQEb}_35N@%)lC1O5V9qC%)qrq3B#sj*%Lc#-LuVx`V7QEXW6Fl5EI2BeMRCHSXI6H zL`?>w``oe)#taJyXK*msB6c}X^>VKMLhT>tbJ*ohglNZL+1*A%_Ux={%}^Z10Ylr% zxBnsR6DT0EVP-@WmZ4}q`%>h&sPS8dH_S~}V_k&{NxhsESI?ezQNc9FU@Z3cxPc0J z``&F*PdhTX?&Dj>qaZoP!{8&jbmN`X8HF+L3SjAb^1k9CU(_Cb#CH}5B^`nR8buUh zGtHd^EM^ z{wUhHWmCV?94Mkm*L~Xw26nlH!G-TwP{D*AQtt)>Rc?MAR;_`3KHMva z>axaq_doxRR4D#p%WTOSo*DFu1t9U zg?{Y{D1w!{r@KAJkm2en>X}YlQTn6I>T%~8QMPf%V5L!hI3}!zoa4aRZNI#t6_MSB zkINMWkt0x(?+DA=Q}bcR_H~oM>0b}eYXh=(tFDiB-q&NJjOT+E#K}->Y;Pe(vT*s5xa$lWX>qYMxN}xLHyorO&dl$&WT;@ zHWHmXl&FXX1du*62?B-jDi7ap&ST2ATn?MwCOKU6QoDsU6y`A#T>I^%+e6mtBCt8-ArrxGlm1wt^7XT4 zIcniOq4pUS{P@Su<}=!tbDQDwcCX-YvxR5)52%c4ZFp)w`M9gqVVCasf3`O#hW})& zXZ0%oy2DM|KHgo9?E?qV8#k@?f7o3Y3s!IYnUf31|E*&G?Vaf;VbOv>T7Dzncni%j zcX)ojOlmdXVLP~gC_AYOprofrwTI_#T%Y}SE&PAUKrA+|1DA=cr`lDKJ>`gvkJl=z z!+b%-e8nW()SK~oOh!Y*BzZfUYNlNX^+vQ#ybre$)Wqq;~VyV zog9PzvV$J~8So4rbQbS6$3)gYoT>sqRsOSdSrC>Y0}hssj-jAWkn9uRj!NgqGS!Oi zBF&223Ayx+g1nAuOmErpFDB?pvs$kWgtZhg4|ahY{#;Vgao~{i`U~O(#E(Tou2F<` zOd?WcRs%xzUjt#vYE2fiCVkfL|4s`|8>+@e>u@Er+)v`sK;I@Nc)R)d9TXLdqgpBdQ95P1r?0zRO_3@R{F-qA;u5&dA0pV zu>J*!B6eEcU|q7>p9rwuAvR520*E6$Ev`5FQBb$zHHVB}He9AWqCu^m2g;lOm^$~P zM4e{!Om>PZl~14Hj~PRXPlipA4=^~g55Ncn24$tgJ$(^pol}FJ8-=iB5p5Os4pwmkk0VrNSl@Z8EUV>9lwSIhX8B? zOSDyvLaa>W`J^1;*d#cP9_wc9ejp1>Rri1*J&yg;IlOHbG zr}};V>~ttUCR=JX_|oag-&==dq4C~%XVcS_53;#DlZODS*Zix3mkm{J6_*rM3HT`D zPfI~xYI^XjDyZU)K%SoH2oB_ zUh-n2$gTJL*!-`eqbv5{kxol`J+F~+{hyB;t?6Tae6^`A%Q~bHQl=tA5k;n44`0uU z#X;Qfr7dLPRmQ`@S|we3D4X>xVh~7AdAMc?FM(JJ!r_Of8OU3|mOM@Gz^+1F z#DYQ~OGKjI*p2L}96Ri0jf>Krfa&nWq=n!uq)rZlgKQcyVP#Vo+ZsvYZq1-}D6qzH z*sDl)n^uPkAL_5#LPz@k50WIKVvT=0a)lp3-Z`ZxV@*gSMYtGoQ8VbT9ef+L;2^Q` zpRG}hzmazehN~0-t=_#QKg9s6cw&&VfO#(JcD*OWZQ_9#^l@4qfrII)?K7@CdD-)aFn$NG2!iCyq&EBF$opl+WEK8VCdv$lt0}?UWxL;j>k-n3kg3+G)-H- zQ87QfQ~zjPPS0%gX4pN_8wU#A1%Mcb3E{u*FJ8{(0EF+0KGe4Nip4LJ@?5MI|O4qrZ?Ug^t#=*^cW!Sl+nZ(Cy0IkAfU&c~e` z;tWWW`h!_oS}r8}uxI!&x;UOM2K4I@l^{qgQadgm?Iq$MXfzEtTl)gSk2QzS_(Rg? zv;V%`CX|S z(~TL99M|z<_#pZQL9dl=z`a+YR;xT%qML(fRM98h1yil4qdTbv*qxDQjuT`;;ZsBx zT=lY~{UM<|bf?kkd??w>f5@JfRmsINwg7MhEABz3TgWv7S|O(?F<0fjx%~1?5(h|| z@)o;;u5T4$$wIP8)0`p|^iAa)Y;$bnmCWGY!iy2kzCtYj^PcqaE)p*3RokA+7t`<| zi4T%LRjG};mFHPFKk^lBSaX73XZYX})I(T*M26dTl9-7%F_`c9V<_9#Xts2 zbQ`zNKn9ZWV=9M@XL2sEL{iz9!}cHVvGDvHjoH_%0+X_b#DC!&B=Ft`=~gQg?9&<; zB@-h?ovtFVy;p(got$Zcv~gR*0&GddMf(gqW;PM#**^v4WrE`Rg{}EnWKJpJV+W?n zm+kY5S^LdW?6`QC)Xh6tHObL~6ntseW6v&P`Cl8!A#Lol%+yg1lQ}?!O?^Z0njflT-DH~*t!MriUno4_?FTeLXhJZuWQw}v{Cewl%Y|1jpfS3@p#dN3o&q!Y zq^6sy+-qh6cJBi)?Nzavd_hfa9a$kr=z6xIbkx% zUIx%JuuT2*yFF8sWkN<+WQ9Mrml1<)|1t`;oRbS?QU!G(4~|!DIY+_DVHj4OY2tWw z6BcElLb5$AG#^{9X@`~N-itQkOfD#+h@Y=r#d*Xyc}+ReIaT#clwu*-+2o+IwLJNZ zD+nX7#fGGAU!eg!~4)Od~yYoV|Hr6z?f6KDp7y6 ztQkbOe$FW=x;$F=YD7~{Q~~VP&5xPWAQZL{aRYG0D#<`_CX8}7A;Kwa7;qQItorfFDHsM$d4ubq?Ws7h)raoG?K;s+J@<4(x)oD{Lm72v96ODy$<+%^_Aof}zp61|3i{69J2A9Ota~ zVu_Vl(xmn+9Z+UNbd(`!hlyaV=Yk*?YNf(W`rS+s<`#jZY{RE0mP>;@tqFxArXk=! zqm_JOa=smkYBqUAnPg$?G9s|wL5<%hg<`eq=w9WTgv@*B;7Z2Hce%;;bYRkLgYB&t z*#Jg=0zDg!2Dd#C;f2YQwlxw)3{s$)p$~0D_7PbLg3|12E1EP_rk2qxx&;5*=g_=C z2sQFrJY|x0GiUK0?*$i?@y2rP|hhv>IO_*qsr z^`lxhm%_3e-2eDH&)E|meXHs@&t7-g3S5BACYrE6qjLUi6BZRNc;IPnGD_MX$9I}@ zKcbX#-uSE}`05~8QNo)|doJ~3)?JC2=EvMCAS3>94H_F=dC^Zm+f3O*=x}yMI>-C0 znPs2TZqEO+=%j@6fSnv|lw=&j2L>ad;14M~#tT|{_xioawC4wq(QP`@UPL)&sAF!P zNzWS4+2?<&-eAx8tyhQtPjGD;%_X91jvtl*;*MRDX#h7S{xa$Z3wwBu*I>0>K7JRi+xS%ZLL>-c%G>%BEkQb0FO z(6?IaFGX4hTJd^bK~8beScy#n|9)C2IlAQJ{_>|pkp>kkF=d)>QjbCq8t1S1@#$WS z&Gp%35CN0UPr|!==d|1dtQdsyds=gK zia`A?u@uyF#x?(HQ5DPtE(7uicW7SHnQ=PX8p4GLN2^+;c>mU}=v;UcCXpA8+y;=br!&|OQ++*nch zDskA?yb=K+Lsy?{qSsdY%kKgZKCT!^Yg{h6Zoh0EOD8=SV7pG?wBDxm3P&N!6%Ykt zh+4W&zVo9cH%pt0u6+q__CB_K8_^mJ-g#{$^K`!NXZe+?n2Vv<4bc5HgMH;)b+y%p z;EO%zQl_{5_b07`6mN^2clP&L+hXCLLyC*fPh9?`Eh@%FI1%wCL#3^8r=||$gxi5> zS(e`dG?dIv2`>>r$H-Y_sAPGb@jltlhCRr_{z*9vGWtvaW6Y+m9B@|Iu6$m9h-M=) zo;o@;8*DA%9-;|gCNNe3qHYl5DmT8m>L#ipOHb39vfD$D%%$E3lYzev;^+fN z>aP0G$cGh}pO_<5)Z?4_VbKT%FLMCh!SG{s3=-DP5I~TMMhxm8mylT&uFrRctYX16 z>boIWCBj^nUOs#S@B__uf<+F5Y)i`8s>3UoTWr4SJ}W8RALk{;t5m8Z{lDny+iAnkmU&H8rI>9KC1UyIjraVfC%c| zZriZUwJaPS4$)8wfP9i{o?Tvob$m@jx47NwJa!9T^<+*qgCAK)Y6xi2|Db_a#b{D4 zdsYs8H7P=1s^xIFG=Mj0Q@mdRaX`X!OM!T_=1=mKI6I1agSMzsIC*KKh}o_(CbJhq zT9|zcT9O&Mq^Te78z}dc;GFH}O4VGFX03OQhAqf+2_l~^tjWKi^H!ZCDs#;mAsfk3 zt-}?K&IFF#=clOU zb29(6keVMas$!0T?^)jQDogbNbz9~eTvdSRvNv%xY+CQg_MNRyQ?ZUqzxm0*&lA5ux>M4Ef`MzUv#W34`Pd+hm4-!Hr|VBaLPL* z7;`cZXMpl3hIsu=5tI+F6(LA4mInSy)QCoi<&%8r0mH}qk8W@KzU#PVOq|y*?@^vH z7Ga(AL8fD*g+a<;a>f<&qo+XjS=E~H&&7Olg9n$%=SzbW1m;X6+qM|K_t-%4#;~}+ z2_i|M7+7@Nl?3e;#t+}AfOg?uTCTMg@JqZaUL#w%j;D)hmf4{j6lLPQ=L(6jXWdq4k2{+_+9l*KD8A#Smy=uuPnS(k~8`T1~&~pBLBNvvSbolQNjGcv zVeETyoLt?r*+=~}ztD_QZI@QFv63mwkmpJ8Pcxbex}6DvzrQCw4ejayh!*=gQ;;Op zLC4U%uNx6y2v+*##9IxZ{=eJc>|oQhA@lO?BbwO^_Yrsm(0%=^iPj69EJG>Lb9bX8 zjr<6(^J?!Gvl)bvMO=*=SJrhmm6`N9G1hY>)rGhUNa4vc2}an_Mv!ee3CIyj))a_2C3UD7Q$c{E*~U{;>~;Cmbd3S2_LT6O{SU8Ws0q+U zA~CR{rz1gJ6-)=fEPzUC+5@ZpYU6}f52uY&Y!&P8?;o|hMw0tXBa43Z`E zrL@qk_Iuq+6xr2Ag+kdbz5O-%)T3=c5~QWS;mOUwVd+F`XhEkS?`esYWZF`fD|^=7 zols>IDnxE4(&qF$b6o{oLo5oMQwY2|6s?i>8IAMh(YFjPBKG7ZZ4_pizYrWKX3Q5P z7-r(Vd{u(4UTJWj2Q-1anO=a4yeT(N=%Ov$35Ia)s7#lj`_CzBr15zI!m?vp+cL}F z^D2;pOS4LxUy+f=x-Fj>hFBTdoyZ|w5?-eL(Wd#FVB^cYu0tMnth2>helkhhe@n2< zIwMo}#1mGdfVUc5^*v!8-B*Z5gJsUr)@m(J!LHFFb-_ASy7)Pf7OH}oMS-)yo)e?0 zK8RmIs1mH7$HhPrtU_|^>>2m|2b%0s+m@|o|1%c!e%KeedQ}^fA_@k-MSh#pc!nc$ zu@6acQyY2(lnRJ)UX#;mVfqCx2AxIpD88LkvSxbyfdk zY@5a}z{TJGHF~mR$1eAC>|&L$;}ttq$Eg)6L`TE=eAml7#R?1W*?PJ^1V%eX%5uEglK$X`fJZ^*# zCf(r7;eT6x5%K@XhC(X^)p*=zK;$% zx=SRm!z+VxS$p_e?H77ztXt{FFptagYJG@Blj+AWef|8em4kx z%QtVoVY@HHYun;sdwd)c-F8QRGQ)VyS1ObQ)p%oMFo ziFg%S>bQPt$a{*-_${R17c{H?H>JZTTVNgV(ty{1djE?wG}?0p9u3$p`vbCxI#3t` zJ8GXx3BhxkE2WLWc6wKzQf-L1^>bF}AzH*XrC=v!Zqp+++L@PPe3+>GClCi zCQV=F;?s%$rk11Ij{kNKnzHkcXSL&9jRQr-t)auq?T3<|iGpGpYW#yP#sUv5lc>|a zp3a0`Md-Pi-b_G5akZi_=nRo%J&;1F0NmBDP@)Ah(i{9)v(s5)8%J3j2#z&Z91ij< z?A_oBSF6GHj2{yicxg-hQjU7ahy&Z`bEx9$EY!!-Gn^p}GGf$u+|a?vSHBg^(Y{Js z?T~HU+~Jada#=!mt$s2@K;+q9r!QK<<}_C|88*$>`E!MSh7pCdJYDsOg1$5JpT4dc zJ$~bD9;N!&rTW2}AQ@*LR2xXOrLeLdpi!Fzp3c0V7VvWXZzS6bmlqQXV}i48+>bzB zL|rV(W95YK2F02)e$PYeY@A4z{vQgiDSX61=Dp6{n%9*>yA*zMm5q$W8JW)Co6TH9 zHy^QGtj?SwR7$nA%AL|gOt#X*@fde${7O?|>AjYdTK^vaoIqp0?QL%>K?bz)>PnrG zm`G!R+dcElGkv?fM0Nc~VoaOe zK=!#Psa^0P;-$6}ABnke6ODE;u9$E@3>U1cv&z+j8B+Et+ac zPpLx2BN-|&TvcB;NYHKyL4||($8{cOVMr6eV{*6#_fx@kn#%*}bOU;Ng{QQGsu)s) zl7=Ha3IE$qws14>hx6TYWVJ5|@sy-{KDbmkA{4+020`G|IzB6GW=EEYmCzvYXXaaa z33`EafRdxQ>x$R*u|c%NbSvGpJHe6ha^EwEIcGKmWGxvyW4$P>sBR=mT^|C|{!J&j zFlRHc-sdasXQ8C;A-D-i9&~@`iv#Kzytg>;(m`6N(*IPOf^FQ9b;38hsM8%%0g2rd z29vXHgpm8!Q3{$xJ4HwkApKjW}wqgL_l@40PI!#VnN6qLr{Rl7wOoBJ5(Y6R9nd8V>~pC@FFXm!<1FUC`zA_d*t`Lq-oZ z{Tj3ywu_|0u)oRPhI;~1L(jt@-t?w7&3#Y{EDR@1jGE7%fY^8ht%ihY=0skt8iBMo z9xxgr(0bj6XEIQ6>s4@4HnX3CHK(A>cZV=xubGDei#yaIaBV zfmgsCb~S+> zIXAHijo#2gRD?Lt+|Ti3Nx@651iLZs2ry4R${}T`fl1>;>A|=hl_kz=3V#%%NzAU$ zHcFE*WNAyY(Se!)IWa=7Jg8aI4ozf2I^&Ep+#gVqUe!&u?=nmc>5JB(WV92Nei*_| zb9U&@gCG20)rQ?-7xQ6hbJrB0AIu>((-+Y{7JlU-sG6g}21z1WAOoX=4O;u)Y)~CS zNdl0G4G=K|rdcuuMSSkbE8Ps|IFxX&Irj!8S^}v+fb)1dIb^^|YNDP$_^>ExC0H#h z&HOCLyT~Ss3AZc(6YdWdV8L=0O)MU^1^pS)qd_Gdb1k! zUkhi`Y|kU7FZ;-gD?fitd4N6kIG3m03xb~4T#znz5MTDAv!7^@u>t>{p3l>LZIh9bsfaaCGf|#}w-x!qaDKaS> z43J`is(>1l)=8I1JCT%25#jA6L*vN{H}#_&s>RW2M;5$efD5PWE6mlY`64p!)b%)u zyzX3_{JSxv4CzxN0_TwqL&;L^eo#^ixhO-5soPCDIeKiETHzB1{SS@@N+um|mv~}X zO35kVDLydvsG07Tvrv-CPo^)Z)VWq9=G_9V1`%&7(=MoS*Sp?zXe|dMFMYy(~i z1Yr7@p|k8`Inm*m7xa@ipK7lEzxM8))!uEb?|3KJoH|%p1mX)2v9lB#D-iPwI(L_bJS+JoBan7)%e87nm#y}!IaAE4 zM2gWPOL?BaJDgqOwuM%n@WD8O2;wonn<%|ndlK_EMkMgv0zI&$ngRQ= z0?n$-tui0=@w@a`T<4jD?beV!gpyvypo+o3l~~EdL!1r?j%&_&$k{iUym>b?JQF^4 z?hTI8;&eO%ZipR*K90|jD|)Vm&JJ5o*MXZn1cMTPiO=&P-a-_hM$Hvl8M#$eBS#0; zm@nI!X=WkGbZ#Tc&1g=KQv=a8C(2Q40@)lnwNONXAL{ozl`8-pKY5Vq%Epz`g6DCm z_<+eG%$ydY<7!*Rk?s;9iPfZGed-OSeo) zX09=q7y^4-q?#YkGWjSQ9T2lp@kYO?Ay z1{SLX%1k^ZZAe2L|6B0@q+Wdnv=+9W{xoRO>;lJJBDAN(=~J{E$DQX;-kNo%A9l6b z%#De*t#hG~o`Jsx2S3;=I_MK1GUdj0s0PC7n4l?J^wBHk`uWON8G)ESYzGR-Q8D)F z(rGuzdKD$%ECfWHIMCF8S*2YZolm+yl_$vKsH!}~97Q3v6tn@{RgLHYLiBmM0hIKK zRVk@X&6w5X>m?+whD_vftW)>^Jr0p^82>=<=UFkgChs@?>Nh0f{VG$7)3%0rw}5Qg zNw#1%TE2p)M;%Bb0avG4^WW@DHv^JaR&2U&_Zjp78c zz-mz2dK3`^WVS|&P6M~g*-pP`hPH7=sqo7_!Tye^UtMsKi_w6#m|t=+QGMNJG+>mZ zQzwC%i>QC0Fd}A~TA2&L8fUr{kI`DV|0G^evSqJbgHyb31lSNf5Rlt-~J#x zzxW)1MgUU=*It)}7X$G~0)LM^!o+;!8F>v#$_eC1?*H?D-z$9kBahd!ZO;*Sj==YA z1YVDl-?x!^_V+mg&k^{3j=<|t^2tknKPl$f>*okG0^Gm8C%HslgOWe`k3S-D|F{43 z_BnTl{w@_6l+8@BrT;n$)gf!zH!+NS1nQ^8<{r<1oTZ3o5Btom1O#u}S_5u)*``>f z&U0}e{}u)Ox1)rzK8@98kfp8H1>O$&aw{wI<9+#_JV{MolG88kFU$3PM?iJns@CkV z2r;l^X5ryD^W`_cI@h`K+&?v2vpMZ?Y2vMMsai!ONaDnVFHMC08kGFqAO9|t{Ja18 zcV9ZJ4?fS%#iqNM6+=-#+@m+3n&(HzJ7&8jElgwV3?uTh&tvu2NK*j zoH6P0NwofrxNrhyJKm8<rGYutl1P+VWJEtXJg& zlx|JJ^QzL#mMQ|v^BlH5&Io@QO6r45w?FeQe&(nD*-!u4zx=gvi+Nibf^@9`-p=hs zx?R>B1$mCOlh^Xrm9nICKZ ztu_f28kQX3gICzP1>nCe0e_0xN^u71u!NwksOl{x#B>Bry1NM(yojRPvNx`!wZk`P ze8na(#s=x5V$Z}}AP-`@kLFlzC({hKTyYDn+KF$8CPj;bAP`ZMPRrcC<#~gie&9*@ zqWB`*Bzy#^l?Q{k)%gV|iKnns1fK{uhCXnL!yt_KQug&l8gk;wz(L_R#hgG8;3b%( zY>eZ%ZQQv}PwdTFf8mK6np;|W;j!GXRf}2kx=Qlt2{rn)6S+`4fgOd`gjZ^Y1l>v)XK^s0AvOQGDHv zimHxvU6Vqnp|GEF@jkeLSwtSrBN7)0Udv{b137eF*AXtY5J|T%Qgug5Nrc@!mW zIQR=9-ms}Y_Upg?>$nEdDKCEJiypA>&~%B=)sM$P?tWlHfQa~+awZmHZ03p@yiDFz z>a#6g6)XZu?-oWT?(1 zs(ixo#o>6-xAfGHVQD={UC=2SC=dMM`0kG+GM(tu(ib@rr?^R&HA*6MS(-JS5_qZ$ ztN*P}lwuC_7;TJOnLT4sCs}<_Nl>qFpvMzl05yt*vm?c!V<^NjnQj8Ot*Cf&Jf_B% zLRi1%-4YrZz)*6dfjs|>c2hG)Vwkn3){=Ae=3fnEE)z~0ZM$8(-a2dXl5}5gt%vuO zkfLy%^|Tx_*nth9q$#i?nv_yKqTAA2m|t*d&AP6r`BZN9JF|^zmgU+(i{F!@F|f-8(4t&$>s1BUFzITGdy4VFoD%2tNO0tO{^G5nF0WFt z;d6qJ-}brWXj)O7jX_>D1Hc6$lYc3y+%gdf*QM1cR!0G^*vuqo&rCDS&V9-&jQzx^ zm|ya;zgks=COvHfo;vl&?0Hzu;z5Z{c=i(|z220;dVh_odFkj)8~lC6@i!sh&-gA@ zt1OL|jmIHc1ymo@%COQ81mk%q7Voxda^{Ge4JCcTl^8k*wF|NFhyVVW z4h}m*%i}r$D-^Dzqb!X@DJjcpp+c-K3@t0iik?gnuDJW6H2e@smI!C#$YwMtkCJ8H zu#jZ2Fe)@F@Pf$#eNPa#>!DIwLRrmk^)rdvA|XweMXe&ILsBI6gqS848w$6Ua3zU; zfr1eSNkP>mN+!0)SOGH6XnB#;Fs=j3)>2{%La|ryi4*2%3>lH!T8~aNw!@r4qG`X~ z;zbigiO@qyqt{G3{vlO>f3vOxJ*F5_fb;>PSWNZPugBB_-8V65QLQ%Y00rF!L23LK{%_m zX;&*|`Nie&h9v#LtLiu!TD;447W6&w(F0^qGL_!C6baVv$3W;E!NvcKrxt0O*D?@q-A60u=N9;tD5Rcz*H>L8XLu1&=(| z`OA=rCuE(T9S$PI)w3mn0EpohnN+>v~hR<+ZfQ zk!F$%D&~|yZxrx(IIM@S_34r&L(;Y}A@)q+#?ZlM{-sBHL!$k>RnhCk#NBcN3-~Y= zhzeQa7V-E=+@;uA`yQiLj-OQ$PqMD6I6FTu8D~n1wYH8|+{U)o?dx{>N+@q>ShjO2 zPNFo@e8*VGmx{GwFxC`wv6mpKHD7ZuVhlD8rhnm*@BZ?%>8Y;;dpbTXqWcM@E- zAlD5of!9(Dt)LreUE&M~)$xSZc8X444;Be%{z{f{iIV9YaJ%RR<4IjRvSjBbZl{7I z;ab(2Jqj271qATaE$mndI80xerYd2?%EIX)Ex}aFZ0!nu+*lbFOx&oS zIxCIfGbm{!GclJgle(ikb=#S=rbc(p8SJB-K6G0D1Kbc6_qxtLHjr+*9gcfI8dB3S zQ{TJWP}>;r#ahyqutGqlr_G}ER@D3H>WQUViEZmb%E#?UZYT*N6LnWf*2OXdxjrXw zrP(a!MHq_x?D#|Vlt#z|ZKrRm7Wr*@0P715Zkbjl-(~2$))Q(gn*)s1NBmd+i!MPA zp(G|5NnxqZ=|{Nlk%X1RjUC%QS1vDN>0A$SF3&aZnKXG19q4epp(pmRq$ zN!7T)XuM=rnjy=wIugV2r<2nc$O0o=9>NBE9vIPnQc{9Q)9SI68%u)Tte{!Xw^Nvs z&G7+c^R*shR;cVo0lK>W=qv<3q5pCKA3{lQ61B){t2simvdO;r`FVC5z;w!M7#5#f z-Tl*W-I?98*sc#``K2z*)oybOC+6!Wc*+Q_lN`kXTvar;KG8xkwu1y-_^<0G79=jxt`fKz;$+GPN&2ipN zoX=U(!$~gqO>T+yn_KBe(-lIuOmjv%0lz)Z{FcqL99(F<Pq+r&l&Ewp()G&Ps>wO}BN%A$sOg*Y$ z9ZY8Vpd5snqQFNV2|9WdB~$+sUQjA|4KD^qtc3SCJTGaE47gEbi)7tcfZF{N95r%# zz~WR1gEi9%=a}si)@^R#BP|+J_GniKyy>E&_ zym@P`rE_aZyZ){$SXS>&zU_n=MkIU|#>#Y5wnG=D+Und6yiM9&)z}6B5MM5S3R7Kh zD_9w=UltohI&^pKYUxbT1E$y@c-wfjP`4E=ul>nrIT z*5#2p5?F_l)U`;Tds8@0Rf5B{Bl_*Hnk^gKo;}$1b84x+O9=R4U*JV&fo-mZmoJv? zdpp1y_E}8@)wr8&2SiRN0j6&bj2*8d%RxQc;MWrSVm|hvDe&*+X<_G`$2S57PKYFKJ<>x_K^&|mm|>WF{aim5yx3gt5U{sMa~)TaV&0XK;j)G2z-$q|fTOa40#d<&4*GnGZ8r zHIvb)>(SorxXE@S;JX$9Y% zt=R|&ShB0nW16Jz!zLqro=m!?*Jk80ckjF#170CF`8Vf$?{*FcPNWT~!qgTU!jkUa z)luK-#Y;l-&V8GL8wKUIF<$JlKM5pO?U4 zi}JvB3Yam>{Se@SCy43FX1y?#DDXuvGQU2pRWPx`7c~sk-3q)h?y_ZOh#y!J5jJXpyJwGY11V>%LSjR&rdGtF(O z6L1qQt}D~rk?uD;tf8*|lN8mXE?=b>Tlzfj-eb)ILf#(Q(cShhfDEWJT{d-?a;{Lh= zPlx@uBhJ@t>htT*5qOTkTaCbLQ1bWx`uE=(CHcgi!JBMeyk1t-Bo*(Oj<=zqd0~qA zB1={;vff%lEWB%Swr>nK?;9#pm(5N0u|RZceFb61$RT0)TKjK4BVUY?9C3d2 z-~8&&{i~n*4?n(6AMz$m6r#&Fa|`ekj>0fH{-``cN?vfn7R<_R$OEj#Ym<-#pFzGL zoNrE=P-m&SxXY|Q4UBGO5$9*eM82llMgc6;;!pVf@qFI8&uGYZPjZbd>y*d9J+xv9 z7gM|j6jcMKQBjY4T(o~T(RZzO?js|;+3nzC=uG9+8A^)yU7@Wwof81qw-bfnq zCiC^I3IXUG zElb?X6R2=+7uA31LZr6xQ7Vk6m(KuM7`hqCVHZSx-}AUJQ#{vJue5Pwdvg-We{jvT z=o4Km_7+ZI>o|dz7HA73FEmgTTK;kPrfFe*j%AeK(!91Dh^P}f=d8EFD<0Mrp*1;0 zuBo;Q*5=ycML$+Q9uv=-FW;ceNgfRq^fG02#{uPlb#BSGI7y^Y4|L;TKP@kQl&4GX zmRsS&nBx&|P@`en;M9#|IoWV*T-`$7d=j4(e&i7-FovVmaAbulo>7~a71uXRgi~d` zRS;MKh&j^D#bB|23$Wo(88q|p9svuMxq`K{C_3o@_Gz99FBM$*hr_#KY__buJeCGS z-JNtBW!_aZ`$55LrteW+QtL5UBE7&w-b|eMEHeQoTMYc0P)3Q-HcqD;U;612<+(67 zrj}ncFq%+yV@)*1G`WI<3#;2V_jztskMK3sqK#dPo&_@jbxW=UX4(o=YFYm1icoSlHR`TW}Q1%K0xe`E; zf7|gVKJ6KM^O|1T#X(8wzC)iW0YWG|-dC7A|8~ZMA8SBLIeT0O(0+`U@uk}z&JoOM zjJqnw*=XB)jF{h7{qfKtr7>?XG{N1#)Y6tHRpaKWhy6w+cnh z;mBg$9n7weMWxQ9EzqD{Wv zikz5z)CLwxcJ|BUukG%nAS!8YyQn%TiB2UzuRP0legxnPD&6+TxUKcLrx}Mbm94`9 z1mh*)k+)za7SFqd20~#!)?h79`d1||)B`K+#lvDq#SxS!7wdoty;MyW>pd#3qR5k$ ztR{#_m^)?ak{Ph9+IX@c#JcN=UV@vunwb+huNpG90T;;f`6(!*XWGsIH z&MO7wV3X&?HETxkQWSIUPPQdn6|}<5xka}qu5NaoShjNZ6n!{iMa*oRSGl}qVoHZo zpa^Xsu$zP^kjpKA>y|^i{7z?T;wcBPoBB|*l*`~Erpzz7=mBe=f&w9&;tRTy(^LFYl zkgdUKMK|@`qzZmya2`TQY{(pybyiBCyLF(#BQ3amro^`dTb2#%*o6JXQpsHF2OWof zLsALSYp3fGPSsYTAejz4hVzoWmHh-z#(@GOk%h2ENvD}Go6IY*4P~X|U>%qv8;$t@ ztQugXv;@|aX9xq^fd|YlS^{2CQh~;u7+#tNWQT?jL5Xe3&;tSeru}4Mk`E13QMqCs zUG%vUi{Yc~djh2ubmIyw@U*iU%4A`qs_zH|x2T=lkZQIE%An(iB0HS+m{tIKd4Ace zC+_*;Cmn$j@SJBS)Sbj0ld;-KZA8?)0ku$&V<@7y-l4R;T*~qCT zC!zmakx1s3inXnktlskU_?JGe)AiO98hVn((B_t9<}4-bnmV&(E85Qy;qny-XaX*8 z!R1bRI19is>W$8$=QL3tk^}Gy2o#?O~LS=<&;aCFhPuxQ&$p~O^URvBU z9h|<6-;i8V1ybbMrbdZd7JhnPe_;@;0afr+4hZaIS#>*?mCDjEnL_N9K`$|72dheQ zGn!+>0SB>}C7mRSLXbE(bO1deX~`4W(9`Idryj=VmdaZ*u!MMRGH+SA*oEMcTZ~BZ zgJ~quUK=`aM*VnOvcF6mv5F`Ljm97&8oL4nWY|&d6peA0E-5zyG|{PK#DC-CN8o7x zxa1}AjHe1_Qlo#v$$Gy2sYig&nhoU6@h_pI2iUs6hM|N=o~>34`zm3WUq6Qm?O<>AF0FlgPGPsR(nf+yFf*FwxcDvCs5Y zn#xY0+BPB>ZQv4(w@fxTK`>V{w!>Sxn5CYsa~rhan6lO1&Td?bwz*EA$PX0FL2aTm z_@lK1yMd!y;B|emL(o7lEZ&wu!?I-#e)Fk(R``)efEF_I%bBNYZT4ocb_*uh+H(?G zGry#Kq;OQN2L`PO=^3L3B~=3E`Z{{XgsprS5X9(h2NU(~W?$;iF*T$#V40bEVr(nx zaSobcU1;8enTH-iN&S)d56a-zG@X2k9b>U!kzm#B%!;=y(z{x~oAUt^4b!miy0xCM z*QYy~KCyb!4@}lrx3-gjO6lEN%&BBjo~#lgOY2GcibX=7PFiNqtnJK_I#RIFFqr1-j;Mf zQEg1gC3D33amTq6B$lW5+O@Hw;0M7?_UKS7Mx^s+EH{{yV`CmdF>w=YTgf^ap?N2fg)+zxa#W zTkJnL=#HW#gRggg=XZW*zO}hBbYg1d%u^ftO#SLFOx(U`UR}pgCgxAta5Nf^Y$AQm zkIoCSjqyxKH>x&B^!U1@7oAK5U`SFZ~|}q#sh(>(~I^*$#KC6 z&R`JbSXGr>yz^(UkOA?4R7UVsILo;w(+zsY3|c9P&zqL7W^8_kiQ)OTXnB4`^{WPW zz8B#Ma4!nzKJUEI!xx}rZ|dX^|NRet{@?!m-~QLXeLva)SWtN5QuD92ur>P3HEr3m zjbOaV$TS*j+d3qgfP5+qa9)&vq*pxJrle36t)Dt?C8^CSvL$E6?!hJo?laM^jn4CI z*>sm@n)gt$^BT{?^1jrj4;NQ!pNZ6NHUizxL_uxhJxEbhgDRl1-5ev z-)}abHGi}b$d*!>EamxWT%x3lKxaiy%mFH;4NOyCRj#b3*V&Juo&rh7MB@kIU*S)+ z>c=*M&P>>fAEz4-+}wOH4J<~@a#*nfK*NVlE-T8}Gjl38aQIU3_lr@|C;#|A|FLWr z|NVde#I5?3>_EFGeY1Ne{D~r1Vk~HUEun_ zH^?rkBO8BO^LYQ*2JPYa&`qCW;-gM#O^#3|m1dL6=hhOH+YG2;G1sSK#Ons(`PL^L z0hDj+_4kqF#Fq(rSI{I`6QWN8?<<145bdp~Fhu@%z|1)>o!tG3CcOqF%fkEZqvR(g z$7i#jBk&x7Pdx%pl>F4w_iX%g1fC=C?j!I-$#);(=Leo6@En0pJp%uPlB^#&Ilowb z>(77d7yjKZ{P=Hv{Nj_(2c9GF9D(NueD)D29^@x(J!AcR{&>J>is_s3mKmF#CCVFXG$l3|Mq5JKRh zZ0ADD&*0Pdp3!Ih&k^{rBOtWw%@^ah(AiIkk^-p3S&Lbg7VJ!LDd$p&d8P>2?u@Wo z#msFPgKj+u_5R6)>K{MwgCG3B(P_ORWzm*}SAxhn_Q(T%OIN$)Au4gi_m{ow z_gfmhwJKqP&U8YqGL=YYb;kH_-?jTnbRgi>q?<>Pvf6feIJNvYrr6QSTcXCxjtHZ6 z4Cj^xLf3@h$|8sfKsFwbiDvDrw`53=tZO^Y;4l8-FPJ_g_U~K(&NPw=O8jc!NUn;i zzsX#Gm3KcSN}{|qcf*H?Mc;omdc#SFK}E*H9dKkH$K=X`YFts=z^ zfR^d9(-BG`VfmE`+scjbUa%0+GwiR9NrzMVlOe81&Z?Srx2F`Q?DGOnv%wnf`fe^Pm6upUd zKt!q3ZM2q=}lZc%?Iw6@xQ*B2b0YCu>s3Ug=yZhu=*Nvh}oZ6bCPRrQcj) zdd5nTK5^P$Yq!KqS(&Tul=nzV``*y``B1Xc6tHh{zIW)2v$x820q+pBrHIt#s3Ww9 z!fu7ila4#hrjub*dn<{tX!!sRKCU?_EW*5;V|1qLiD|3&AWO|g-HEt!EfdNnxuEA0io zU_G2<=nNeEgD36(|0dX?XONA`W#~iIpykF&LbkeZQ5hBw7(@U1%F3Gd@t8up@(%$=p>{9YyJE%SV=$w69eHbk<3q zjC^iwhq6tzNUJ$LGopHi(15*iSL!Lqg{tkio$}OS@u-Ll1dOB&+(ribhH}6SdBRkl z9B3zomJ=4R+Wu8&tBVxTfYc{zVrDE}pD>%XzYMBYR(;2=_JG>_wiYzNzuMNh&ENr?cetMi&%Q~ zc*UZHvn2yp|K-ujLcXFsjAnX|{hoKt{WjS82Xg;&%n3r&{9! za9c&Br-o}w4Ttx%Ze0gV{RrrBwR4yRHLfdAV>mnL4;SnTfBBbxDK&lVx4N)zHa0ti zw4qxr{&QipM6tJSNs1K%1t{&8b2ibOJnR{x*WpN83i?}1&Ysa|DCnvrI_1n)PiJ;^ zgxr>*)MH6Jy1ZrB>suH!chSb0&I*)hyorX$WnV@NPVxYVHvY~mQ7(_Go-UN~+2E&T zBLDhVPMwlKPrZ99(?pEK+1DJ$D$&vy~#&XntHBzT!1j&uxsE!0uWQ9F1zp*vI! zsn28SPpZ-?xhy@r>M`sP9Smtt(pn2_UNpk-9c1|qoLLs z${o@5lKQdd-U2HFLpobZX2@d0Xwab8pL*4n6P!gMLzwx6y^PL4k2l6L> z@+W?P3FEKMHAi29r!A@+v0KsLD#UtkwsKzyrTdm zRME^}VEt<JI@dvr3YEEH$8LFO0Cr!({eS$(4nzAjVGzp;H~CNj+2<#cH%@lS8T?1 z(~d7TVzS&vX*vsA?FxR|;X0U4wduHYjXGE}yz5^(gbKSpZR-G%(PKV1;2-m`Pll2q z0wZsuNw*@WY;u;}GRbrb79honS#%!OIRU27-SxOG_+kwpkud_SZ^h*iKbKwt}{=fDMEh6ZiC!DGoQ*hsi-~MrH z(QYG?=3zrg+^&n{xXs`Iad5;0*Z@UGhWCko#3!nsrOR(jZr{Q}LIKcedjJ{9u=<4$ z!kMlnrs?T9eXaa+cNJ&`Ghr_;CtCGolRIqNg(gxYqpH8(SwOhrq9*zr(g z2-w;I@ijh`0<+uf7B1RrXP?F*ctAa~kg z>iMxRwLc^4nX{IME5Mh&91+h^i){?(`##wPeC_`)mP?cj$Y|MCV*;sjbY$t#U?>KJ z{lSl9G2>|#GS1Ld_4Q7voPu-Ik@eUK3$NqST-;7OsRjdXdw_s*+SK%`!-}(~GquGu zrJal?!uY>92tAO7PGkIr>|;jjsE?GeZK%YN6h0N((j7BJXD)5FDeCEXbLc;GyJ{gK zzg69_^}-h|5X`}usosm72)~vr*N8S9uCH$oWczkEl%!MI9!U8}_^|!g@!?_9!!S%e zIMg5cvCo8(odB~IoC`@d;o5q*9B8YkSI)c1p_hfsemo(v@! zKt{zwZR)OPTn8LyogpA@cSK8WG_F{X^Kh@T$#i8-M)o^vCgJx!k--R$oh~DnZ-LYlX?DRj>Nh6$oC!d#3bCM9Dx-M@?2kWMijHDdhW>4oFr_-h-+ z*@ZZxczK9}_epO{MMxgMMGTii(cUmR7F9t;T4qg(7@*R;NU}-ipj@gW=gIi26#-+_ zmB5|CB<_3V#nQu4dfb|8#(fVEt+oeP4qXS_+jYv>M4XiSvCL~peTj2idCB#GCY|NV zd7RsHq+3nh^h<$D^N2f^1azJ+CYIWG=N6vAmGxlm)L7?1-d>4Q ze(|)gG-wZ^BtE^Bv$ocj;3APZr3Z61eMMePdg{rH21dXzvRTncfTYxskd%^S>y%f( zN}AkTw(LZQlUwv|d&#-5-KzbZM*YS7x1SZ!CtiW*Y`$#W!2F@mLCo-5zjhG zNU=8Gce9ViMh{yJXb-m-2h9#f7vTqG_tc;F<)#*2zuq7d=QlFTDT)xVUB?BxPDYv2 z>$V5t?s2BKP%=LaN?TfKKUM^fHZFI#uZVZA`}Ai*N!q|$RMK?vTaV&G^ zN&UW($aAs@HGcThFG9&Io@>m8od(e>1go5H;*~&QrVJ0K0^SZ>%Rz)FO4Kz1I=c>C zCR7#L7&@|fEilww+svit8Y^dO-2AQ*w{d(F)_$ad1YYT9aIZN$!vKA+E;0*^TlvSof+Z^Nv@x-g)3W(!HjN0o$6|4o$3%lie6| z1M6b!#VwRf=K$P^3w{h63t0WDe3K)+UWaJ!@z!TT$sAlTHRnJiblZ3>#noQy$<$IO z=?q(u$IO&uhm)jwpf#|1>DJYRGStjb7t32XQT!yJ71s2MmwJOY)l7nuHEd4%f!q z&TWn|YANfB5ZslvnH1|A=RQ#AM+%vw3Z-|A_IqH~w_oRHLP@S6hW;|HE>&6%8TgAa zv@w9912cV6Z7M2+vJE;V{hbL0XYStOL^hOEb98S>(6}Xo;QL@@4vY$ZbDWvFqHKVe z58ya;;@9j4aG!KWG)ur#3dqB2jCq|aU%kuU=)r)KY{!(LAdku^D8~`EH^e@S6pVB4 zu5l*2ZH_y+(dGMT1Uz8C3I1RrU^SjrR7PPaJlhW)MLNFCT)R$I7FfGrW;&974t>VN zV`FulSH{i7Gjx}((-e4ALUHh_); zO*0!?Y614$FZCt(_JQwQ!W1f_Or-NVH1Ui$i*vjZ z%C?iQdn|`r8luZl=6B|!E2n9-Ajc@1?7zn0LKbTvSg`;wo0<*j0P9*gnMEUUmumJ( z>h`S&3tNL~-L?M7U$a{zzMbd3U-lyYcP)gn@nOQc;t@ta&}PG!1G060t1!FMsH>Vm zD>>_ye&x!FAB+doJ2u?$UZCr|6KBHk6;A1UP;RI#*V>RcueHOXYV+(US?97rZ6S!)&*00H&!*0JxN1b^N_E!4-F zXE3W4;celnzG#T5oc^Rq+`4H(+j3%KH_bzWd<`lj5PR0zoJuDb+Pj0uCU zg?B&BY-;nZf%_<)Hh}ID9AE@ArgdcrkvEw{83E3m9l(eze~J5NvvZ@f8u1Yp8@_cF zOWDoVO#<{_xQY-xi!YMGW;LqEstf3`{A(5;KuNBT!BI>BCTjv5Z;h=Q#yiKnukxv( z#yV{qB;JJ~D>uGv7g! z{L&$KKL33i0TL^NOqs#H8zFSxw;BAF`};)6Z+XCQgv8zxCea6h7Ux8SjpM2*Lc+jAC zrxjGFd?bV<@cMeh&i)NM#7e`kb1TT=8&>{tUjK+FNt@0sW>QG3gT;=$bBu6Ts2oS7NixLDiilljq#oP%iv*!BtPNh|K5`4wt+7m* z>^u*evtRt~`GCF~%yC9xDByd*lzaxy5N5TOHJMr8*uJRdXO%WqY~tsU16wn+fkAc) z2Y93}2qU-C`^88yF7r$MBB0@Q$jyK^=z|9Ctqq8oYZM;+0JC$?5$Rld}1dM#cu19xF`D;tetfcfO&0BO_*(@$m8x|TD*KtVTzIJOo)q`h8 z37UZhL`Lz!V7o*49Oew~D<<)!*?ar@9}y*G=xLhQ+*1acKY7>7pMcmPM(IzDBtjNf(sUlpz%s~HsDZE2r9!;y^_4s$1PFFbrJwHei}UK)O1 zdMt1AoDauT$oFDGUpW2P1FRozYirFwyhwutOS#9E(-$RH67IE@jA1~ zQe^I!nE7CMeoG2hLJ(C=-tSA==5G&4Ifrr0S$R*R#5vT^nMzn@|88nZUsS+FN!_QI zS#t#P#)HRBoEx0K+EetW&PtcHoM;zx7fFbzjGbUnerk% z5KU1K_|VeQte(R;xLHdWC8X(#(t`2h<;Pw$h{9veN3nh?wD)oz_R&dysm)wFg@Sl% zOL=gTixpLnhL%93`elu2vy@hwlerJI!a=`?;LF3L)&vTDuAJLvKEyEPvcRLVpJ@P$ z){9zFo>%)&X((+Nmb76ky5>%{!G_NER)mbniqS5e2T)kKn_}}V@2Fxc91ky#^if;! zJexmhIXbsWP#PmRu&@~zYMb1!PM(}Z?mWf@Qngvof$6@L>$9LkWYd7FaCKUY3E)g# zA|fN^@I@nrImM!q5B#+>R(D{}Ov=u(d#sttxOwIsNqmWDk;gb-8lq`=0g(@G@Y#us za2x!ju!Er(+hTmJ4lHfnsTht;$`(#qddiJ}8*s}rx;2c(hpRkd?(1t-mk*1T9}y+X z8P(p^S$BAlkOGCZ-e5<;f|qFp!dXZ$et)7HZQZT3;*{^?n(tsESs2WEkcw4FhAl6V z#j|l_2+?vX+;wv*#s%B+RTYH|S*s08c;7M5M~f!nARRx26N#_Hw6;HU0=KjfeToPW z5X4X6g$}_9aRw8Qd=rzCs7l-dY}P6|z;(6j?ZOau+DqYqNk4#Y;wh zWsRK34o%e;lwZTtunug28zp9L7c4Nef{-4}%=FdK^CO~U8C%W0-3H=V{2f_U>ZgL+ ztkQVN%kYdCz%{gEz({Q|1zTQ*btVe3F6p5Xg&}Ugo9yiX;(50$ZC1`XuL0<-Lq~7X zLB|qc1lAQun?vM;I|cUQv$ofPFL0;0XdG_EQ_fq7MW@_UW-ah>>cToR@BDOimcYVT z^{+JyyTwOt`Fo}T@m2#Fn7j5!+TM$CLk@)+Q`x)c^=%a8G>{ZVcc#xJ-4iG z9sW}i!ag(!MsE@P$GrX#QL@>hM{5oceWcJ?gGHEOAw$U&z+33TPgd1Gh8W^&(lps5 zZEleow6gMP;*gi!QdRgblqA+7XuymN<5To;SvHa&HapKuZQ-kE3qmpe*mCih-37`q zUo>viLqfzfi4@}^2Iul*9$4UcEX=J`rX^+Zz%9c_n!y*(txsun9$AhHPAocAnJu%) zd~7gr*^_#<M>Hm3Y7a}l0}sjwEaVOlFYzpao>Sg%;TE-&PkSy%y^ad{G9UE zE(KU|g{gN|N-hgjTH3m7aI8n)y%UQ50fF)(qGS@cVI>)rrB$J^LOnL^#K~bdXx~yQ zF|>_EGC7EhSu#YWWrhyQ<)zwD%Je|G7(j`L{cr@JMP)lxXQ`g!5c}5fL<}P~Ou{>} zkLIlhlUA+PCWI=Zwd~fa3+u$>t;FJOVZz%pZ51Rr7YBk%v>t$bIH#3?%`FTPS2x7Kdd%VTzf*?6oFNv6q*S0z83f!uWL7 z#nd(1WVP#)QGyJL-Lgr>zNK2}1h@1LXkMMvJGL>f5`TT3zTM6pg|pSzoLVC~GGXi? zEDA9}JxNCPp*i3TNvFhI`F${*C19X4mN1KulF*Rys{L&A?C2!itidb$?Ba`OkbEet z{D>%7y5^EN*m(c}nK!IS)Uqb$xR_Q;*1h3nFl${`Of#0m^AQskZ_@4qK6(}qVew!(t`ItZc_sCXmri*edjN z&Y=HLXn3nJ9}y+nuTEovK9tSS{H04mudD~KhRm`W7H#9Efw98M9AjK7$DU>u@daD9 zm6En9Aqzsc>=COU!zg8ZRMOMgY1N6BO#h-)Yc8qBr$4>tc^+9B*j1}oR-`F;mbBh5 zs#?Asez4`!z15_gKQ@=o9u6h#CAgAQW>bk_#A8ByYb?b=hl`E`O1ZH%J}us<$}9!F zVvSivzE~!-nEf!+Ev$XKNec|P;Iqmrx*e5m_jV20AmN6WmfOkyUem zrLI99uAGeUBqt46%~bCy24@mmBx|42M#eE)QMu7>mC2LePBSQ3N9Ny*_?;)5Eoe;G z=|(dFz=3G{-P6AOTLlLv35@JXY;X7fG=XS86K*@m)#K6rXb zEBW&L1K-VS=RCcq1g->#O+*c#_AvEV#Za{^tI**gD100Z;(>&9Jg#)r^P>2 z_2o%N5DmY)g%#oK!Zp-lCp!->O98zdY)SrbQ2|>}X{B%|KU(tesJ^G8tmKil9iWK6 zPP#|)X&A>D)~ysNc^X8(`Dv^r#V*3WMY?f|BiKdivk`76Nu)zC7=Nbr6bO@nFiv^= zM!pu_z3#go5hXiBa84swJ2pq(MA?@{4}!K^1ex{4;u*@%?8kQKE3qZfHI=Ao-pq?x z0|;x@YHGys6U2h$xn?8H&U0yR$-8K?x(@kX9+R7;*fQ8yIXr1>CcQy+k6Te|`39W$ zyN-;$r)9fzUJE5p7fXzig}vHCF>XP2eAc>wLAM4F-kmYOx!|2_PRk5C6L+5tineR4 zUIrmv(y+UgXq<`T(p4+YS`FJ$$)Rf9m92KzC)g(bcRi`itxf?#pJ^C2IJRSjp&JXo zo-onOu3wDslvvu-KJ}|&c(WQ!;1GRxL=}VC2(MafqJ2tF+N8GCiO8C2a}DvdT~qv`X;2c-wYtN~>`}r}Oqa zuG5ywSOLl*U09&z&LH)$*2bA?>dH0DcM(mjlW!G@uCp|Ny#Psd$k#CLAu0b{8uJ#5KvPI)F3;RfBsS$ znf4HicWEGs$2^^@;uC$Y1vbWz=0Y*fhSv|Qz3Be5ehNjz<$CKwx`0{`NtQHm-RpTE06g-FAWn@-RMrj0fvHlhIPI@1rE5>XnUy>EHGA zv^6I?|#4-aiciKpYlq0?=)O@F1 z(!)fjrX&v=g(eYc#+!6-He$7y�*k-)Wj4Lct+ZP(Yf&c*)B`f0S|)3x+5sZ~}a9 zhUt|Q=nYzyTTzLCip~PPEJ^p>dNDUORn)C|(gt9?C&;=kz?ZVv;=x$}f(A;=#$irh z0ZhruWdB=yL>%bJ@CJ@HxfsUb0gZolE#Cz{olu&gWsb zqQcP(+flr;-hC|`y#L*gh?1BQw0;?ii_~CQI!8(t^I@&xcZ@rR$hso!5+Xdt#F6E} z(A$Jpw+s_WC*2}ftWtVIq0m{k@zwA#Me|d671CA%MGo9RD z%&Cr;adW-)0^r&_q7Yo61{(y6?S4+R{0=R@)X+= zbwS>^czMgy%3U=K9||r%B1(R11B8ZNM36UcOi6m~>k8leqpz;e5Lvg7Xd<#zWtU3E zw3uNfc(SBVCu$NXfP3)>9kH92_9b%hrE}7&`_hZw{)~Ua27mR}=^55IV0bzqo+jP& zooaDh&gR)>v$5GzxnRS`_rPwn#ngZG&R#a)dySGGb4s4AdXB(z1U}RVJW=vPO~ _inputObjectBuffer = []; - private string? _sourceExtensionHint; - private string? _sourceBaseDirectory; - - /// - /// String content to render with syntax highlighting. - /// - [Parameter( - Mandatory = true, - ValueFromPipeline = true, - ValueFromPipelineByPropertyName = true, - Position = 0 - )] - [AllowEmptyString] - [AllowNull] - [Alias("FullName", "Path")] - - public PSObject? InputObject { get; set; } - +public sealed class ShowTextMateCmdlet : TextMateCmdletBase { /// /// TextMate language ID for syntax highlighting (e.g., 'powershell', 'csharp', 'python'). /// If not specified, detected from file extension (for files) or defaults to 'powershell' (for strings). @@ -41,12 +22,6 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [ArgumentCompleter(typeof(LanguageCompleter))] public string? Language { get; set; } - /// - /// Color theme to use for syntax highlighting. - /// - [Parameter] - public ThemeName Theme { get; set; } = ThemeName.DarkPlus; - /// /// When present, force use of the standard renderer even for Markdown grammars. /// This can be used to preview alternate rendering behavior. @@ -54,149 +29,31 @@ public sealed class ShowTextMateCmdlet : PSCmdlet { [Parameter] public SwitchParameter Alternate { get; set; } - protected override void ProcessRecord() { - if (MyInvocation.ExpectingInput) { - if (InputObject?.BaseObject is FileInfo file) { - try { - foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); - } - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, file)); - } - } - else if (InputObject?.BaseObject is string inputString) { - // Extract extension hint and base directory from PSPath if available - if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { - GetSourceHint(); - } - - // Buffer the input string for later processing - _inputObjectBuffer.Add(inputString); - } - } - else if (InputObject is not null) { - FileInfo file = new(GetUnresolvedProviderPathFromPSPath(InputObject?.ToString())); - if (!file.Exists) return; - try { - foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); - } - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, file)); - } - } - } + protected override string FixedToken => string.Empty; - /// - /// Finalizes processing after all pipeline records have been processed. - /// - protected override void EndProcessing() { + protected override bool UsesMarkdownBaseDirectory => true; - try { - if (_inputObjectBuffer.Count == 0) { - // WriteVerbose("No string input provided"); - return; - } - if (_sourceExtensionHint is null || _sourceBaseDirectory is null) { - GetSourceHint(); - } - HighlightedText? result = ProcessStringInput(); - if (result is not null) { - WriteObject(result); - } - } - catch (Exception ex) { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); - } - } + protected override bool UsesExtensionHint => true; - private HighlightedText? ProcessStringInput() { - // Normalize buffered strings into lines - string[] lines = TextMateHelper.NormalizeToLines(_inputObjectBuffer); + protected override bool UseAlternate => Alternate.IsPresent; - if (lines.AllIsNullOrEmpty()) { - WriteVerbose("All input strings are null or empty"); - return null; - } + protected override string? DefaultLanguage => "powershell"; - // Resolve language (explicit parameter, pipeline extension hint, or default) - string effectiveLanguage = !string.IsNullOrEmpty(Language) ? Language : - !string.IsNullOrEmpty(_sourceExtensionHint) ? _sourceExtensionHint : - "powershell"; + protected override (string token, bool asExtension) ResolveTokenForStringInput() { + string effectiveLanguage = !string.IsNullOrEmpty(Language) + ? Language + : !string.IsNullOrEmpty(SourceExtensionHint) + ? SourceExtensionHint + : DefaultLanguage ?? "powershell"; WriteVerbose($"effectiveLanguage: {effectiveLanguage}"); - (string? token, bool asExtension) = TextMateResolver.ResolveToken(effectiveLanguage); - - // Process and wrap in HighlightedText - IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: Alternate.IsPresent); - - return renderables is null - ? null - : new HighlightedText { - Renderables = renderables - }; + return TextMateResolver.ResolveToken(effectiveLanguage); } - private IEnumerable ProcessPathInput(FileInfo filePath) { - // FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(fileinfo)); - - if (!filePath.Exists) { - throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); - } - - // Set the base directory for relative image path resolution in markdown - // Use the full directory path or current directory if not available - string markdownBaseDir = filePath.DirectoryName ?? Environment.CurrentDirectory; - Rendering.ImageRenderer.CurrentMarkdownDirectory = markdownBaseDir; - WriteVerbose($"Set markdown base directory for image resolution: {markdownBaseDir}"); - - // Resolve language: explicit parameter > file extension - (string token, bool asExtension) = !string.IsNullOrWhiteSpace(Language) + protected override (string token, bool asExtension) ResolveTokenForPathInput(FileInfo filePath) { + return !string.IsNullOrWhiteSpace(Language) ? TextMateResolver.ResolveToken(Language) : (filePath.Extension, true); - - // Single file processing - WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); - - string[] lines = File.ReadAllLines(filePath.FullName); - IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: Alternate.IsPresent); - - if (renderables is not null) { - yield return new HighlightedText { - Renderables = renderables - }; - } - } - - private void GetSourceHint() { - if (InputObject is null) return; - string? hint = InputObject.Properties["PSPath"]?.Value as string - ?? InputObject.Properties["FullName"]?.Value as string - ?? InputObject.Properties["PSChildName"]?.Value as string; - if (string.IsNullOrEmpty(hint)) return; - - WriteVerbose($"Language Hint: {hint}"); - - if (_sourceExtensionHint is null) { - string ext = Path.GetExtension(hint); - if (!string.IsNullOrWhiteSpace(ext)) { - _sourceExtensionHint = ext; - WriteVerbose($"Detected extension hint from PSPath: {ext}"); - } - } - // remove potential Provider stuff from string. - hint = GetUnresolvedProviderPathFromPSPath(hint); - if (_sourceBaseDirectory is null) { - string? baseDir = Path.GetDirectoryName(hint); - if (!string.IsNullOrWhiteSpace(baseDir)) { - _sourceBaseDirectory = baseDir; - Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; - WriteVerbose($"Set markdown base directory from PSPath: {baseDir}"); - } - } } } diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs index 87107e3..e1ae352 100644 --- a/src/Cmdlets/TextMateCmdletBase.cs +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -8,11 +8,12 @@ namespace PSTextMate.Commands; /// -/// Base cmdlet for rendering input using a fixed TextMate language or extension token. +/// Base cmdlet for rendering input using TextMate language or extension tokens. /// public abstract class TextMateCmdletBase : PSCmdlet { private readonly List _inputObjectBuffer = []; private string? _sourceBaseDirectory; + private string? _sourceExtensionHint; /// /// String content or file path to render with syntax highlighting. @@ -34,6 +35,12 @@ public abstract class TextMateCmdletBase : PSCmdlet { [Parameter] public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + /// + /// When present, render a gutter with line numbers. + /// + [Parameter] + public SwitchParameter LineNumbers { get; set; } + /// /// Fixed language or extension token used for rendering. @@ -50,6 +57,11 @@ public abstract class TextMateCmdletBase : PSCmdlet { /// protected virtual bool UsesMarkdownBaseDirectory => false; + /// + /// Indicates whether an extension hint should be inferred from pipeline metadata. + /// + protected virtual bool UsesExtensionHint => false; + /// /// Error identifier used for error records. /// @@ -60,6 +72,21 @@ public abstract class TextMateCmdletBase : PSCmdlet { /// protected virtual bool UseAlternate => false; + /// + /// Default language token used when no explicit input is available. + /// + protected virtual string? DefaultLanguage => null; + + /// + /// Resolved extension hint from the pipeline input. + /// + protected string? SourceExtensionHint => _sourceExtensionHint; + + /// + /// Resolved base directory for markdown rendering. + /// + protected string? SourceBaseDirectory => _sourceBaseDirectory; + protected override void ProcessRecord() { if (MyInvocation.ExpectingInput) { if (InputObject?.BaseObject is FileInfo file) { @@ -74,8 +101,8 @@ protected override void ProcessRecord() { } } else if (InputObject?.BaseObject is string inputString) { - if (UsesMarkdownBaseDirectory) { - EnsureBaseDirectoryFromInput(); + if (UsesMarkdownBaseDirectory || UsesExtensionHint) { + EnsureSourceHints(); } _inputObjectBuffer.Add(inputString); @@ -105,8 +132,8 @@ protected override void EndProcessing() { return; } - if (UsesMarkdownBaseDirectory) { - EnsureBaseDirectoryFromInput(); + if (UsesMarkdownBaseDirectory || UsesExtensionHint) { + EnsureSourceHints(); } HighlightedText? result = ProcessStringInput(); @@ -127,13 +154,14 @@ protected override void EndProcessing() { return null; } - (string token, bool asExtension) = ResolveFixedToken(); + (string token, bool asExtension) = ResolveTokenForStringInput(); IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: UseAlternate); return renderables is null ? null : new HighlightedText { - Renderables = renderables + Renderables = renderables, + ShowLineNumbers = LineNumbers.IsPresent }; } @@ -148,7 +176,7 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { WriteVerbose($"Set markdown base directory for image resolution: {markdownBaseDir}"); } - (string token, bool asExtension) = ResolveFixedToken(); + (string token, bool asExtension) = ResolveTokenForPathInput(filePath); WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); string[] lines = File.ReadAllLines(filePath.FullName); @@ -156,12 +184,21 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { if (renderables is not null) { yield return new HighlightedText { - Renderables = renderables + Renderables = renderables, + ShowLineNumbers = LineNumbers.IsPresent }; } } - private (string token, bool asExtension) ResolveFixedToken() { + protected virtual (string token, bool asExtension) ResolveTokenForStringInput() { + return ResolveFixedToken(); + } + + protected virtual (string token, bool asExtension) ResolveTokenForPathInput(FileInfo filePath) { + return ResolveFixedToken(); + } + + protected (string token, bool asExtension) ResolveFixedToken() { if (!FixedTokenIsExtension) { return TextMateResolver.ResolveToken(FixedToken); } @@ -170,8 +207,12 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { return (token, true); } - private void EnsureBaseDirectoryFromInput() { - if (_sourceBaseDirectory is not null || InputObject is null) { + private void EnsureSourceHints() { + if (InputObject is null) { + return; + } + + if (_sourceBaseDirectory is not null && _sourceExtensionHint is not null) { return; } @@ -182,12 +223,27 @@ private void EnsureBaseDirectoryFromInput() { return; } - string resolvedPath = GetUnresolvedProviderPathFromPSPath(hint); - string? baseDir = Path.GetDirectoryName(resolvedPath); - if (!string.IsNullOrWhiteSpace(baseDir)) { - _sourceBaseDirectory = baseDir; - Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; - WriteVerbose($"Set markdown base directory from input: {baseDir}"); + if (_sourceExtensionHint is null) { + string ext = Path.GetExtension(hint); + if (string.IsNullOrWhiteSpace(ext)) { + string resolvedHint = GetUnresolvedProviderPathFromPSPath(hint); + ext = Path.GetExtension(resolvedHint); + } + + if (!string.IsNullOrWhiteSpace(ext)) { + _sourceExtensionHint = ext; + WriteVerbose($"Detected extension hint from input: {ext}"); + } + } + + if (_sourceBaseDirectory is null) { + string resolvedPath = GetUnresolvedProviderPathFromPSPath(hint); + string? baseDir = Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrWhiteSpace(baseDir)) { + _sourceBaseDirectory = baseDir; + Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; + WriteVerbose($"Set markdown base directory from input: {baseDir}"); + } } } } diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs index eb1dc90..d8bc63a 100644 --- a/src/Core/HighlightedText.cs +++ b/src/Core/HighlightedText.cs @@ -1,5 +1,7 @@ using Spectre.Console; using Spectre.Console.Rendering; +using System.Globalization; +using System.Linq; namespace PSTextMate.Core; @@ -14,6 +16,26 @@ public sealed class HighlightedText : Renderable { /// public required IRenderable[] Renderables { get; init; } + /// + /// When true, prepend line numbers with a gutter separator. + /// + public bool ShowLineNumbers { get; init; } + + /// + /// Starting line number for the gutter. + /// + public int LineNumberStart { get; init; } = 1; + + /// + /// Optional fixed width for the line number column. + /// + public int? LineNumberWidth { get; init; } + + /// + /// Separator inserted between the line number and content. + /// + public string GutterSeparator { get; init; } = " │ "; + /// /// Number of lines contained in this highlighted text. /// @@ -25,7 +47,12 @@ public sealed class HighlightedText : Renderable { protected override IEnumerable Render(RenderOptions options, int maxWidth) { // Delegate to Rows which efficiently renders all renderables var rows = new Rows(Renderables); - return ((IRenderable)rows).Render(options, maxWidth); + + if (!ShowLineNumbers) { + return ((IRenderable)rows).Render(options, maxWidth); + } + + return RenderWithLineNumbers(rows, options, maxWidth); } /// @@ -34,7 +61,102 @@ protected override IEnumerable Render(RenderOptions options, int maxWid protected override Measurement Measure(RenderOptions options, int maxWidth) { // Delegate to Rows for measurement var rows = new Rows(Renderables); - return ((IRenderable)rows).Measure(options, maxWidth); + + if (!ShowLineNumbers) { + return ((IRenderable)rows).Measure(options, maxWidth); + } + + return MeasureWithLineNumbers(rows, options, maxWidth); + } + + private IEnumerable RenderWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { + (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); + return PrefixLineNumbers(segments, options, width, contentWidth); + } + + private Measurement MeasureWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { + (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); + Measurement measurement = ((IRenderable)rows).Measure(options, contentWidth); + int gutterWidth = width + GutterSeparator.Length; + return new Measurement(measurement.Min + gutterWidth, measurement.Max + gutterWidth); + } + + private (List segments, int width, int contentWidth) RenderInnerSegments(Rows rows, RenderOptions options, int maxWidth) { + int width = ResolveLineNumberWidth(LineCount); + int contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); + List segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); + + int actualLineCount = CountLines(segments); + int actualWidth = ResolveLineNumberWidth(actualLineCount); + if (actualWidth != width) { + width = actualWidth; + contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); + segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); + } + + return (segments, width, contentWidth); + } + + private IEnumerable PrefixLineNumbers(List segments, RenderOptions options, int width, int contentWidth) { + int lineNumber = LineNumberStart; + + foreach (List line in SplitLines(segments)) { + string label = lineNumber.ToString(CultureInfo.InvariantCulture).PadLeft(width) + GutterSeparator; + foreach (Segment segment in ((IRenderable)new Text(label)).Render(options, contentWidth)) { + yield return segment; + } + + foreach (Segment segment in line) { + yield return segment; + } + + yield return Segment.LineBreak; + lineNumber++; + } + } + + private static IEnumerable> SplitLines(IEnumerable segments) { + List current = new(); + bool sawLineBreak = false; + + foreach (Segment segment in segments) { + if (segment.IsLineBreak) { + yield return current; + current = new List(); + sawLineBreak = true; + continue; + } + + current.Add(segment); + } + + if (current.Count > 0 || !sawLineBreak) { + if (current.Count > 0) { + yield return current; + } + } + } + + private static int CountLines(List segments) { + if (segments.Count == 0) { + return 0; + } + + int lineBreaks = segments.Count(segment => segment.IsLineBreak); + if (lineBreaks == 0) { + return 1; + } + + return segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; + } + + private int ResolveLineNumberWidth(int lineCount) { + if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { + return LineNumberWidth.Value; + } + + int lastLineNumber = LineNumberStart + Math.Max(0, lineCount - 1); + return lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; } /// @@ -44,7 +166,7 @@ protected override Measurement Measure(RenderOptions options, int maxWidth) { /// Border style to use (default: Rounded) /// Panel containing the highlighted text public Panel ToPanel(string? title = null, BoxBorder? border = null) { - Panel panel = new(new Rows([.. Renderables])); + Panel panel = new(this); if (!string.IsNullOrEmpty(title)) { panel.Header(title); @@ -67,12 +189,12 @@ public Panel ToPanel(string? title = null, BoxBorder? border = null) { /// /// Padding to apply /// Padder containing the highlighted text - public Padder WithPadding(Padding padding) => new(new Rows([.. Renderables]), padding); + public Padder WithPadding(Padding padding) => new(this, padding); /// /// Wraps the highlighted text with uniform padding on all sides. /// /// Padding size for all sides /// Padder containing the highlighted text - public Padder WithPadding(int size) => new(new Rows([.. Renderables]), new Padding(size)); + public Padder WithPadding(int size) => new(this, new Padding(size)); } diff --git a/src/Rendering/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs index b0172ab..a6178dc 100644 --- a/src/Rendering/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -82,7 +82,7 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW int defaultMaxWidth = maxWidth ?? 80; // Default to ~80 characters wide for terminal display int defaultMaxHeight = maxHeight ?? 30; // Default to ~30 lines high - if (TryCreateSixelImage(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { + if (TryCreateSixelRenderable(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { // Return the sixel image directly. The caller may append an explicit Text.NewLine // so it renders as a separate row (avoids embedding the blank row inside the same widget). return sixelImage; @@ -135,7 +135,7 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int int width = maxWidth ?? 60; // Default max width for inline images int height = maxHeight ?? 20; // Default max height for inline images - if (TryCreateSixelImage(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { + if (TryCreateSixelRenderable(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { return sixelImage; } else { @@ -150,23 +150,78 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int } /// - /// Attempts to create a SixelImage using reflection for forward compatibility. + /// Attempts to create a sixel renderable using the newest available implementation. /// - /// Path to the image file - /// Maximum width - /// Maximum height - /// The created SixelImage, if successful - /// True if SixelImage was successfully created - private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { + private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { + if (TryCreatePixelImage(imagePath, maxWidth, out result)) { + return true; + } + + return TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); + } + + /// + /// Attempts to create a PixelImage from PwshSpectreConsole using reflection. + /// + private static bool TryCreatePixelImage(string imagePath, int? maxWidth, out IRenderable? result) { result = null; try { - // Try multiple approaches to find SixelImage + Type? pixelImageType = Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); + if (pixelImageType is null) { + return false; + } + + ConstructorInfo? constructor = pixelImageType.GetConstructor([typeof(string), typeof(bool)]); + if (constructor is null) { + _lastSixelError = "Constructor not found for PixelImage with (string, bool) parameters"; + return false; + } - // First, try the direct approach - SixelImage is in Spectre.Console namespace + object? pixelInstance; + try { + pixelInstance = constructor.Invoke([imagePath, false]); + } + catch (Exception ex) { + _lastSixelError = $"Failed to invoke PixelImage constructor: {ex.InnerException?.Message ?? ex.Message}"; + return false; + } + + if (pixelInstance is null) { + _lastSixelError = "PixelImage constructor returned null"; + return false; + } + + if (maxWidth.HasValue) { + PropertyInfo? maxWidthProperty = pixelImageType.GetProperty("MaxWidth"); + if (maxWidthProperty?.CanWrite == true) { + maxWidthProperty.SetValue(pixelInstance, maxWidth.Value); + } + } + + if (pixelInstance is IRenderable renderable) { + result = renderable; + return true; + } + } + catch (Exception ex) { + _lastSixelError = ex.Message; + } + + return false; + } + + /// + /// Attempts to create a Spectre.Console SixelImage using reflection for backward compatibility. + /// + private static bool TryCreateSpectreSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { + result = null; + + try { + // Try the direct approach - SixelImage is in Spectre.Console namespace // but might be in different assemblies (Spectre.Console vs Spectre.Console.ImageSharp) Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console") ?? Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); + ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); // If that fails, search through loaded assemblies if (sixelImageType is null) { @@ -207,11 +262,11 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma // Create SixelImage instance ConstructorInfo? constructor = sixelImageType.GetConstructor([typeof(string), typeof(bool)]); if (constructor is null) { - _lastSixelError = $"Constructor not found for SixelImage with (string, bool) parameters"; + _lastSixelError = "Constructor not found for SixelImage with (string, bool) parameters"; return false; } - object? sixelInstance = null; + object? sixelInstance; try { sixelInstance = constructor.Invoke([imagePath, false]); // false = animation disabled } @@ -221,7 +276,7 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma } if (sixelInstance is null) { - _lastSixelError = $"SixelImage constructor returned null"; + _lastSixelError = "SixelImage constructor returned null"; return false; } diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..0d28f04 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh +param([switch]$Load) +$s = { + param([string]$Path, [switch]$LoadOnly) + $Parent = Split-Path $Path -Parent + Import-Module (Join-Path $Parent 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + Import-Module (Join-Path $Path 'output' 'PSTextMate.psd1') + if (-not $LoadOnly) { + Format-Markdown (Join-Path $Path 'tests' 'test-markdown.md') + } +} +if ($Load) { + . $s -LoadOnly -Path $PSScriptRoot +} else { + pwsh -nop -c $s -args $PSScriptRoot +} From 4d092a8742c2752efd97ffd1844507be8af85f0c Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:49:35 +0100 Subject: [PATCH 19/25] cleanup --- .gitignore | 1 - Module/PSTextMate.format.ps1xml | 72 -------- .../Core/Markdown/MarkdownPipelinesTests.cs | 50 ------ .../Core/Markdown/MarkdownRendererTests.cs | 81 --------- .../Core/Markdown/TableRendererTests.cs | 33 ---- .../Core/StandardRendererTests.cs | 92 ---------- .../Core/TextMateProcessorTests.cs | 169 ------------------ .../SpectreTextMateStylerTests.cs | 101 ----------- PSTextMate.Tests/Core/TokenProcessorTests.cs | 130 -------------- .../StringBuilderExtensionsTests.cs | 91 ---------- PSTextMate.Tests/GlobalUsings.cs | 8 - .../Infrastructure/CacheManagerTests.cs | 111 ------------ .../Integration/RenderingIntegrationTests.cs | 45 ----- .../Integration/TaskListIntegrationTests.cs | 146 --------------- .../TaskListReflectionRemovalTests.cs | 101 ----------- PSTextMate.Tests/PSTextMate.Tests.csproj | 29 --- PSTextMate.Tests/demo-textmate.ps1 | 123 ------------- PSTextMate.Tests/line-test-1.md | 18 -- PSTextMate.Tests/line-test-2.md | 18 -- .../tests/Core/StandardRendererTests.cs | 0 .../tests/Core/TextMateProcessorTests.cs | 0 .../tests/Core/TokenProcessorTests.cs | 0 .../tests/Infrastructure/CacheManagerTests.cs | 0 _build.ps1 | 42 ----- tests/Format-CSharp.tests.ps1 | 6 + tests/Format-Markdown.tests.ps1 | 6 + tests/Format-PowerShell.tests.ps1 | 6 + tests/Get-SupportedTextMate.tests.ps1 | 6 + tests/Show-TextMate.tests.ps1 | 6 + tests/Test-SupportedTextMate.tests.ps1 | 6 + 30 files changed, 36 insertions(+), 1461 deletions(-) delete mode 100644 PSTextMate.Tests/Core/Markdown/MarkdownPipelinesTests.cs delete mode 100644 PSTextMate.Tests/Core/Markdown/MarkdownRendererTests.cs delete mode 100644 PSTextMate.Tests/Core/Markdown/TableRendererTests.cs delete mode 100644 PSTextMate.Tests/Core/StandardRendererTests.cs delete mode 100644 PSTextMate.Tests/Core/TextMateProcessorTests.cs delete mode 100644 PSTextMate.Tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs delete mode 100644 PSTextMate.Tests/Core/TokenProcessorTests.cs delete mode 100644 PSTextMate.Tests/Extensions/StringBuilderExtensionsTests.cs delete mode 100644 PSTextMate.Tests/GlobalUsings.cs delete mode 100644 PSTextMate.Tests/Infrastructure/CacheManagerTests.cs delete mode 100644 PSTextMate.Tests/Integration/RenderingIntegrationTests.cs delete mode 100644 PSTextMate.Tests/Integration/TaskListIntegrationTests.cs delete mode 100644 PSTextMate.Tests/Integration/TaskListReflectionRemovalTests.cs delete mode 100644 PSTextMate.Tests/PSTextMate.Tests.csproj delete mode 100644 PSTextMate.Tests/demo-textmate.ps1 delete mode 100644 PSTextMate.Tests/line-test-1.md delete mode 100644 PSTextMate.Tests/line-test-2.md delete mode 100644 PSTextMate.Tests/tests/Core/StandardRendererTests.cs delete mode 100644 PSTextMate.Tests/tests/Core/TextMateProcessorTests.cs delete mode 100644 PSTextMate.Tests/tests/Core/TokenProcessorTests.cs delete mode 100644 PSTextMate.Tests/tests/Infrastructure/CacheManagerTests.cs delete mode 100644 _build.ps1 diff --git a/.gitignore b/.gitignore index 60b7758..1c7b30a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ debug.md .github/prompts/* ref/** Copilot-Processing.md -PSTextMate-Rendering-Analysis.md diff --git a/Module/PSTextMate.format.ps1xml b/Module/PSTextMate.format.ps1xml index af6082c..f108d53 100644 --- a/Module/PSTextMate.format.ps1xml +++ b/Module/PSTextMate.format.ps1xml @@ -41,77 +41,5 @@ - - TextMateDebug - - PwshSpectreConsole.TextMate.Test+TextMateDebug - - - - - - - - - - - - - - - - - - - return 'Line {0}, Index {1}' -f $_.LineIndex, ($_.StartIndex, $_.EndIndex -join '-') - - - - Text - - - - return $_.Scopes -join ', ' - - - - - - - - - TextMateDebug - - PwshSpectreConsole.TextMate.Core.TokenDebugInfo - - - - - - - - - - - - - - - Text - - - - return 'fg: {0}, bg: {1}, deco: {2}, link: {3}' -f ( - $_.Style.Foreground.ToMarkup(), - $_.Style.Background.ToMarkup(), - $_.Style.Decoration, - $_.Style.Link) - - - - - - - diff --git a/PSTextMate.Tests/Core/Markdown/MarkdownPipelinesTests.cs b/PSTextMate.Tests/Core/Markdown/MarkdownPipelinesTests.cs deleted file mode 100644 index d3fc91c..0000000 --- a/PSTextMate.Tests/Core/Markdown/MarkdownPipelinesTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Xunit; -using PwshSpectreConsole.TextMate.Core.Markdown; - -namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; - -public class MarkdownPipelinesTests -{ - [Fact] - public void Standard_ReturnsSamePipelineInstance() - { - var pipeline1 = MarkdownPipelines.Standard; - var pipeline2 = MarkdownPipelines.Standard; - - Assert.Same(pipeline1, pipeline2); - } - - [Fact] - public void Standard_HasRequiredExtensions() - { - var pipeline = MarkdownPipelines.Standard; - - // Pipeline should be configured for tables, emphasis extras, etc. - var markdown = "| Header |\n|--------|\n| Cell |"; - var doc = Markdig.Markdown.Parse(markdown, pipeline); - - Assert.NotNull(doc); - } - - [Fact] - public void Standard_ParsesTaskLists() - { - var pipeline = MarkdownPipelines.Standard; - - var markdown = "- [ ] Task\n- [x] Done"; - var doc = Markdig.Markdown.Parse(markdown, pipeline); - - Assert.NotNull(doc); - } - - [Fact] - public void Standard_ParsesAutoLinks() - { - var pipeline = MarkdownPipelines.Standard; - - var markdown = "https://example.com"; - var doc = Markdig.Markdown.Parse(markdown, pipeline); - - Assert.NotNull(doc); - } -} diff --git a/PSTextMate.Tests/Core/Markdown/MarkdownRendererTests.cs b/PSTextMate.Tests/Core/Markdown/MarkdownRendererTests.cs deleted file mode 100644 index cf450d5..0000000 --- a/PSTextMate.Tests/Core/Markdown/MarkdownRendererTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; - -public class MarkdownRendererTests -{ - [Fact] - public void Render_SimpleMarkdown_ReturnsValidRows() - { - // Arrange - var markdown = "# Hello World\nThis is a test."; - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act - var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdown, theme, themeName); - - // Assert - result.Should().NotBeNull(); - result.Should().NotBeEmpty(); - } - - [Fact] - public void Render_EmptyMarkdown_ReturnsEmptyRows() - { - // Arrange - var markdown = ""; - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act - var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdown, theme, themeName); - - // Assert - result.Should().NotBeNull(); - result.Should().BeEmpty(); - } - - [Fact] - public void Render_CodeBlock_ProducesCodeBlockRenderer() - { - // Arrange - var markdown = "```csharp\nvar x = 1;\n```"; - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act - var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdown, theme, themeName); - - // Assert - result.Should().NotBeNull(); - result.Should().NotBeEmpty(); - // Additional assertions for code block rendering can be added - } - - [Theory] - [InlineData("# Heading 1")] - [InlineData("## Heading 2")] - [InlineData("### Heading 3")] - public void Render_Headings_HandlesAllLevels(string markdownHeading) - { - // Arrange - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act - var result = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer.Render(markdownHeading, theme, themeName); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCount(1); - } - - private static Theme CreateTestTheme() - { - // Use the internal CacheManager to get a cached Theme instance for tests - var (registry, theme) = PwshSpectreConsole.TextMate.Infrastructure.CacheManager.GetCachedTheme(ThemeName.DarkPlus); - return theme; - } -} diff --git a/PSTextMate.Tests/Core/Markdown/TableRendererTests.cs b/PSTextMate.Tests/Core/Markdown/TableRendererTests.cs deleted file mode 100644 index 87db19e..0000000 --- a/PSTextMate.Tests/Core/Markdown/TableRendererTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq; -using Markdig; -using Markdig.Syntax; -using Markdig.Extensions.Tables; -using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; - -public class TableRendererTests -{ - [Fact] - public void ExtractTableDataOptimized_DoesNotDuplicateHeaders() - { - // Arrange: simple markdown table - var markdown = "| Name | Value |\n| ---- | ----- |\n| Alpha | 1 |\n| Beta | 2 |"; - var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); - var document = Markdig.Markdown.Parse(markdown, pipeline); - var table = document.OfType().FirstOrDefault(); - var (_, theme) = PwshSpectreConsole.TextMate.Infrastructure.CacheManager.GetCachedTheme(ThemeName.DarkPlus); - - // Act - var rows = TableRenderer.ExtractTableDataOptimized(table ?? throw new InvalidOperationException("table not found"), theme); - - // Assert - // First row should be header, and should not appear twice in data rows - rows.Should().NotBeNull(); - rows.Should().HaveCount(3); // header + 2 data rows - rows[0].isHeader.Should().BeTrue(); - rows.Skip(1).All(r => !r.isHeader).Should().BeTrue(); - } -} diff --git a/PSTextMate.Tests/Core/StandardRendererTests.cs b/PSTextMate.Tests/Core/StandardRendererTests.cs deleted file mode 100644 index e6e2d7b..0000000 --- a/PSTextMate.Tests/Core/StandardRendererTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using PwshSpectreConsole.TextMate.Core; -using PwshSpectreConsole.TextMate.Infrastructure; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Core; - -public class StandardRendererTests -{ - [Fact] - public void Render_WithValidInput_ReturnsRows() - { - // Arrange - string[] lines = ["function Test-Function {", " Write-Host 'Hello'", "}"]; - var (grammar, theme) = GetTestGrammarAndTheme(); - - // Act - var result = StandardRenderer.Render(lines, theme, grammar); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCount(3); - } - - [Fact] - public void Render_WithEmptyLines_HandlesGracefully() - { - // Arrange - string[] lines = ["", "test", ""]; - var (grammar, theme) = GetTestGrammarAndTheme(); - - // Act - var result = StandardRenderer.Render(lines, theme, grammar); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCount(3); - } - - [Fact] - public void Render_WithSingleLine_ReturnsOneRow() - { - // Arrange - string[] lines = ["$x = 1"]; - var (grammar, theme) = GetTestGrammarAndTheme(); - - // Act - var result = StandardRenderer.Render(lines, theme, grammar); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCount(1); - } - - [Fact] - public void Render_WithDebugCallback_InvokesCallback() - { - // Arrange - string[] lines = ["$x = 1"]; - var (grammar, theme) = GetTestGrammarAndTheme(); - var debugInfos = new List(); - - // Act - var result = StandardRenderer.Render(lines, theme, grammar, info => debugInfos.Add(info)); - - // Assert - result.Should().NotBeNull(); - debugInfos.Should().NotBeEmpty(); - } - - [Fact] - public void Render_PreservesLineOrder() - { - // Arrange - string[] lines = ["# Line 1", "# Line 2", "# Line 3"]; - var (grammar, theme) = GetTestGrammarAndTheme(); - - // Act - var result = StandardRenderer.Render(lines, theme, grammar); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCount(3); - result.Should().ContainInOrder(result); - } - - private static (IGrammar grammar, Theme theme) GetTestGrammarAndTheme() - { - var (registry, theme) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - var grammar = CacheManager.GetCachedGrammar(registry, "powershell", isExtension: false); - return (grammar!, theme); - } -} diff --git a/PSTextMate.Tests/Core/TextMateProcessorTests.cs b/PSTextMate.Tests/Core/TextMateProcessorTests.cs deleted file mode 100644 index 99c1ec7..0000000 --- a/PSTextMate.Tests/Core/TextMateProcessorTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using PwshSpectreConsole.TextMate.Core; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Core; - -public class TextMateProcessorTests -{ - [Fact] - public void ProcessLines_WithValidInput_ReturnsRows() - { - // Arrange - string[] lines = ["$x = 1", "$y = 2"]; - - // Act - var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "powershell", isExtension: false); - - // Assert - result.Should().NotBeNull(); - result!.Should().HaveCount(2); - } - - [Fact] - public void ProcessLines_WithEmptyArray_ReturnsNull() - { - // Arrange - string[] lines = []; - - // Act - var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "powershell", isExtension: false); - - // Assert - result.Should().BeNull(); - } - - [Fact] - public void ProcessLines_WithNullInput_ThrowsArgumentNullException() - { - // Arrange - string[] lines = null!; - - // Act - Action act = () => TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "powershell", isExtension: false); - - // Assert - act.Should().Throw() - .WithParameterName("lines"); - } - - [Fact] - public void ProcessLines_WithInvalidGrammar_ThrowsInvalidOperationException() - { - // Arrange - string[] lines = ["test"]; - - // Act - Action act = () => TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, "invalid-grammar-xyz", isExtension: false); - - // Assert - act.Should().Throw() - .WithMessage("*Grammar not found*"); - } - - [Fact] - public void ProcessLines_WithExtension_ResolvesGrammar() - { - // Arrange - string[] lines = ["function Test { }"]; - - // Act - var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, ".ps1", isExtension: true); - - // Assert - result.Should().NotBeNull(); - result!.Should().HaveCount(1); - } - - [Fact] - public void ProcessLinesCodeBlock_PreservesRawContent() - { - // Arrange - string[] lines = ["", "[brackets]"]; - - // Act - var result = TextMateProcessor.ProcessLinesCodeBlock(lines, ThemeName.DarkPlus, "html", isExtension: false); - - // Assert - result.Should().NotBeNull(); - result!.Should().HaveCount(2); - } - - [Fact] - public void ProcessLinesInBatches_WithValidInput_YieldsBatches() - { - // Arrange - var lines = Enumerable.Range(1, 100).Select(i => $"Line {i}"); - int batchSize = 25; - - // Act - var batches = TextMateProcessor.ProcessLinesInBatches(lines, batchSize, ThemeName.DarkPlus, "powershell", isExtension: false); - var batchList = batches.ToList(); - - // Assert - batchList.Should().HaveCount(4); - batchList[0].BatchIndex.Should().Be(0); - batchList[0].FileOffset.Should().Be(0); - batchList[1].BatchIndex.Should().Be(1); - batchList[1].FileOffset.Should().Be(25); - } - - [Fact] - public void ProcessLinesInBatches_WithInvalidBatchSize_ThrowsArgumentOutOfRangeException() - { - // Arrange - var lines = new[] { "test" }; - - // Act - Action act = () => { var _ = TextMateProcessor.ProcessLinesInBatches(lines, 0, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); }; - - // Assert - act.Should().Throw() - .WithParameterName("batchSize"); - } - - [Fact] - public void ProcessFileInBatches_WithNonExistentFile_ThrowsFileNotFoundException() - { - // Arrange - string filePath = "non-existent-file.txt"; - - // Act - Action act = () => { var _ = TextMateProcessor.ProcessFileInBatches(filePath, 100, ThemeName.DarkPlus, "powershell", isExtension: false).ToList(); }; - - // Assert - act.Should().Throw(); - } - - [Theory] - [InlineData("csharp")] - [InlineData("python")] - [InlineData("javascript")] - [InlineData("markdown")] - public void ProcessLines_WithDifferentLanguages_Succeeds(string language) - { - // Arrange - string[] lines = ["// comment", "var x = 1;"]; - - // Act - var result = TextMateProcessor.ProcessLines(lines, ThemeName.DarkPlus, language, isExtension: false); - - // Assert - result.Should().NotBeNull(); - } - - [Theory] - [InlineData(ThemeName.DarkPlus)] - [InlineData(ThemeName.Light)] - [InlineData(ThemeName.Monokai)] - public void ProcessLines_WithDifferentThemes_Succeeds(ThemeName theme) - { - // Arrange - string[] lines = ["$x = 1"]; - - // Act - var result = TextMateProcessor.ProcessLines(lines, theme, "powershell", isExtension: false); - - // Assert - result.Should().NotBeNull(); - } -} diff --git a/PSTextMate.Tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs b/PSTextMate.Tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs deleted file mode 100644 index c2c8a4a..0000000 --- a/PSTextMate.Tests/Core/TextMateStyling/SpectreTextMateStylerTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Xunit; -using Spectre.Console; -using TextMateSharp.Themes; -using TextMateSharp.Grammars; -using PwshSpectreConsole.TextMate.Core.TextMateStyling; -using PwshSpectreConsole.TextMate.Infrastructure; - -namespace PwshSpectreConsole.TextMate.Tests.Core.TextMateStyling; - -public class SpectreTextMateStylerTests -{ - private readonly SpectreTextMateStyler _styler; - private readonly Theme _theme; - - public SpectreTextMateStylerTests() - { - _styler = new SpectreTextMateStyler(); - _theme = CreateTestTheme(); - } - - [Fact] - public void GetStyleForScopes_WithValidScopes_ReturnsStyle() - { - var scopes = new[] { "source.cs", "keyword.other.using.cs" }; - - var style = _styler.GetStyleForScopes(scopes, _theme); - - // May return null if theme has no rule for these scopes, which is valid - // Test passes if no exception thrown - Assert.True(true); - } - - [Fact] - public void GetStyleForScopes_CachesResults() - { - var scopes = new[] { "source.cs", "keyword.other.using.cs" }; - - var style1 = _styler.GetStyleForScopes(scopes, _theme); - var style2 = _styler.GetStyleForScopes(scopes, _theme); - - // If styles are returned, should be same instance (cached) - if (style1 != null && style2 != null) - { - Assert.Same(style1, style2); - } - } - - [Fact] - public void GetStyleForScopes_WithEmptyScopes_ReturnsNull() - { - var style = _styler.GetStyleForScopes([], _theme); - Assert.Null(style); - } - - [Fact] - public void GetStyleForScopes_WithNullScopes_ReturnsNull() - { - var style = _styler.GetStyleForScopes(null!, _theme); - Assert.Null(style); - } - - [Fact] - public void ApplyStyle_WithValidText_ReturnsText() - { - var style = new Style(Color.Red); - var text = _styler.ApplyStyle("hello", style); - - Assert.NotNull(text); - } - - [Fact] - public void ApplyStyle_WithNullStyle_ReturnsPlainText() - { - var text = _styler.ApplyStyle("hello", null); - - Assert.NotNull(text); - } - - [Fact] - public void ApplyStyle_WithEmptyString_ReturnsEmptyText() - { - var text = _styler.ApplyStyle("", new Style()); - - Assert.Equal(0, text.Length); - } - - [Fact] - public void ApplyStyle_WithNullString_ReturnsEmptyText() - { - var text = _styler.ApplyStyle(null!, new Style()); - - Assert.Equal(0, text.Length); - } - - private static Theme CreateTestTheme() - { - // Use cached theme for tests - var (_, theme) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - return theme; - } -} diff --git a/PSTextMate.Tests/Core/TokenProcessorTests.cs b/PSTextMate.Tests/Core/TokenProcessorTests.cs deleted file mode 100644 index 1e1db06..0000000 --- a/PSTextMate.Tests/Core/TokenProcessorTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Text; -using PwshSpectreConsole.TextMate.Core; -using PwshSpectreConsole.TextMate.Infrastructure; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Tests.Core; - -public class TokenProcessorTests -{ - [Fact] - public void ProcessTokensBatch_WithEscapeMarkup_EscapesSpecialCharacters() - { - // Arrange - var (grammar, theme) = GetTestGrammarAndTheme(); - string line = "[markup] "; - var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); - var builder = new StringBuilder(); - - // Act - TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: true); - string result = builder.ToString(); - - // Assert - markup should be escaped - (result.Contains("[[") || result.Contains("]]") || result.Contains("<")).Should().BeTrue(); - } - - [Fact] - public void ProcessTokensBatch_WithoutEscapeMarkup_PreservesRawContent() - { - // Arrange - var (grammar, theme) = GetTestGrammarAndTheme(); - string line = "[markup]"; - var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); - var builder = new StringBuilder(); - - // Act - TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: false); - string result = builder.ToString(); - - // Assert - when not escaping, raw brackets should be in result - result.Should().NotBeEmpty(); - } - - [Fact] - public void ProcessTokensBatch_WithDebugCallback_InvokesCallback() - { - // Arrange - var (grammar, theme) = GetTestGrammarAndTheme(); - string line = "$x = 1"; - var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); - var builder = new StringBuilder(); - var debugInfos = new List(); - - // Act - TokenProcessor.ProcessTokensBatch( - tokenResult.Tokens, - line, - theme, - builder, - debugCallback: info => debugInfos.Add(info), - lineIndex: 0, - escapeMarkup: true - ); - - // Assert - debugInfos.Should().NotBeEmpty(); - debugInfos[0].LineIndex.Should().Be(0); - debugInfos[0].Text.Should().NotBeNullOrEmpty(); - } - - [Fact] - public void ExtractThemeProperties_CachesResults() - { - // Arrange - var (grammar, theme) = GetTestGrammarAndTheme(); - string line = "$x = 1"; - var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); - var firstToken = tokenResult.Tokens[0]; - - // Act - call twice with same token - var result1 = TokenProcessor.ExtractThemeProperties(firstToken, theme); - var result2 = TokenProcessor.ExtractThemeProperties(firstToken, theme); - - // Assert - both calls should return same cached result - result1.Should().Be(result2); - } - - [Fact] - public void ProcessTokensBatch_WithEmptyLine_HandlesGracefully() - { - // Arrange - var (grammar, theme) = GetTestGrammarAndTheme(); - string line = ""; - var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); - var builder = new StringBuilder(); - - // Act - TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: true); - string result = builder.ToString(); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public void ProcessTokensBatch_WithMultipleTokens_ProcessesAll() - { - // Arrange - var (grammar, theme) = GetTestGrammarAndTheme(); - string line = "$variable = 'string'"; - var tokenResult = grammar.TokenizeLine(line, null, TimeSpan.MaxValue); - var builder = new StringBuilder(); - - // Act - TokenProcessor.ProcessTokensBatch(tokenResult.Tokens, line, theme, builder, escapeMarkup: true); - string result = builder.ToString(); - - // Assert - result.Should().NotBeEmpty(); - result.Length.Should().BeGreaterThan(0); - } - - private static (IGrammar grammar, Theme theme) GetTestGrammarAndTheme() - { - var (registry, theme) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - var grammar = CacheManager.GetCachedGrammar(registry, "powershell", isExtension: false); - return (grammar!, theme); - } -} diff --git a/PSTextMate.Tests/Extensions/StringBuilderExtensionsTests.cs b/PSTextMate.Tests/Extensions/StringBuilderExtensionsTests.cs deleted file mode 100644 index 51056c1..0000000 --- a/PSTextMate.Tests/Extensions/StringBuilderExtensionsTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using PwshSpectreConsole.TextMate.Extensions; - -namespace PwshSpectreConsole.TextMate.Tests.Extensions; - -public class StringBuilderExtensionsTests -{ - [Fact] - public void AppendLink_ValidUrlAndText_GeneratesCorrectMarkup() - { - // Arrange - var builder = new StringBuilder(); - var url = "https://example.com"; - var text = "Example Link"; - - // Act - builder.AppendLink(url, text); - - // Assert - var result = builder.ToString(); - result.Should().Be("[link=https://example.com]Example Link[/]"); - } - - [Fact] - public void AppendWithStyle_NoStyle_AppendsTextOnly() - { - // Arrange - var builder = new StringBuilder(); - var text = "Hello World"; - - // Act - builder.AppendWithStyle(null, text); - - // Assert - builder.ToString().Should().Be("Hello World"); - } - - [Fact] - public void AppendWithStyle_WithStyle_GeneratesStyledMarkup() - { - // Arrange - var builder = new StringBuilder(); - var style = new Style(Color.Red, Color.Blue, Decoration.Bold); - var text = "Styled Text"; - - // Act - builder.AppendWithStyle(style, text); - - // Assert - var result = builder.ToString(); - result.Should().Contain("red"); - result.Should().Contain("blue"); - result.Should().Contain("bold"); - result.Should().Contain("Styled Text"); - result.Should().StartWith("["); - result.Should().EndWith("[/]"); - } - - [Theory] - [InlineData("")] - [InlineData(null)] - public void AppendWithStyle_NullOrEmptyText_HandlesGracefully(string? text) - { - // Arrange - var builder = new StringBuilder(); - var style = new Style(Color.Red); - - // Act - builder.AppendWithStyle(style, text); - - // Assert - var result = builder.ToString(); - result.Should().NotBeNull(); - result.Should().StartWith("["); - result.Should().EndWith("[/]"); - } - - [Fact] - public void AppendWithStyle_SpecialCharacters_EscapesMarkup() - { - // Arrange - var builder = new StringBuilder(); - var text = "[brackets] and "; - - // Act - builder.AppendWithStyle(null, text); - - // Assert - var result = builder.ToString(); - result.Should().Be("[brackets] and "); - } -} diff --git a/PSTextMate.Tests/GlobalUsings.cs b/PSTextMate.Tests/GlobalUsings.cs deleted file mode 100644 index f73575b..0000000 --- a/PSTextMate.Tests/GlobalUsings.cs +++ /dev/null @@ -1,8 +0,0 @@ -global using Xunit; -global using FluentAssertions; -global using System.Text; -global using Spectre.Console; -global using TextMateSharp.Themes; -global using PwshSpectreConsole.TextMate.Core; -global using PwshSpectreConsole.TextMate.Core.Markdown; -global using PwshSpectreConsole.TextMate.Extensions; diff --git a/PSTextMate.Tests/Infrastructure/CacheManagerTests.cs b/PSTextMate.Tests/Infrastructure/CacheManagerTests.cs deleted file mode 100644 index 5336aba..0000000 --- a/PSTextMate.Tests/Infrastructure/CacheManagerTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using PwshSpectreConsole.TextMate.Infrastructure; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Infrastructure; - -public class CacheManagerTests -{ - [Fact] - public void GetCachedTheme_ReturnsSameInstanceOnRepeatedCalls() - { - // Arrange - var themeName = ThemeName.DarkPlus; - - // Act - var (registry1, theme1) = CacheManager.GetCachedTheme(themeName); - var (registry2, theme2) = CacheManager.GetCachedTheme(themeName); - - // Assert - registry1.Should().BeSameAs(registry2); - theme1.Should().BeSameAs(theme2); - } - - [Theory] - [InlineData(ThemeName.DarkPlus)] - [InlineData(ThemeName.Light)] - [InlineData(ThemeName.Monokai)] - public void GetCachedTheme_WorksForAllThemes(ThemeName themeName) - { - // Act - var (registry, theme) = CacheManager.GetCachedTheme(themeName); - - // Assert - registry.Should().NotBeNull(); - theme.Should().NotBeNull(); - } - - [Fact] - public void GetCachedGrammar_ReturnsSameInstanceOnRepeatedCalls() - { - // Arrange - var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - string grammarId = "powershell"; - - // Act - var grammar1 = CacheManager.GetCachedGrammar(registry, grammarId, isExtension: false); - var grammar2 = CacheManager.GetCachedGrammar(registry, grammarId, isExtension: false); - - // Assert - grammar1.Should().BeSameAs(grammar2); - } - - [Fact] - public void GetCachedGrammar_WithExtension_LoadsCorrectGrammar() - { - // Arrange - var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - string extension = ".ps1"; - - // Act - var grammar = CacheManager.GetCachedGrammar(registry, extension, isExtension: true); - - // Assert - grammar.Should().NotBeNull(); - grammar!.GetName().Should().Be("PowerShell"); - } - - [Fact] - public void GetCachedGrammar_WithLanguageId_LoadsCorrectGrammar() - { - // Arrange - var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - string languageId = "csharp"; - - // Act - var grammar = CacheManager.GetCachedGrammar(registry, languageId, isExtension: false); - - // Assert - grammar.Should().NotBeNull(); - } - - [Fact] - public void GetCachedGrammar_WithInvalidGrammar_ReturnsNull() - { - // Arrange - var (registry, _) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - string invalidGrammar = "invalid-grammar-xyz"; - - // Act - var grammar = CacheManager.GetCachedGrammar(registry, invalidGrammar, isExtension: false); - - // Assert - grammar.Should().BeNull(); - } - - [Fact] - public void ClearCache_RemovesAllCachedItems() - { - // Arrange - var (registry1, theme1) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - var grammar1 = CacheManager.GetCachedGrammar(registry1, "powershell", isExtension: false); - - // Act - CacheManager.ClearCache(); - var (registry2, theme2) = CacheManager.GetCachedTheme(ThemeName.DarkPlus); - var grammar2 = CacheManager.GetCachedGrammar(registry2, "powershell", isExtension: false); - - // Assert - new instances after clear - registry1.Should().NotBeSameAs(registry2); - theme1.Should().NotBeSameAs(theme2); - } -} diff --git a/PSTextMate.Tests/Integration/RenderingIntegrationTests.cs b/PSTextMate.Tests/Integration/RenderingIntegrationTests.cs deleted file mode 100644 index 167e22e..0000000 --- a/PSTextMate.Tests/Integration/RenderingIntegrationTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Diagnostics; -using FluentAssertions; -using Xunit; - -namespace PSTextMate.Tests.Integration; - -public class RenderingIntegrationTests -{ - private static string RunRepScript() - { - var psi = new ProcessStartInfo() - { - FileName = "pwsh", - Arguments = "-NoProfile -File .\\rep.ps1", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = System.IO.Path.GetFullPath(".") - }; - - using var p = Process.Start(psi)!; - string stdout = p.StandardOutput.ReadToEnd(); - string stderr = p.StandardError.ReadToEnd(); - p.WaitForExit(30000); - - if (p.ExitCode != 0) - { - throw new Exception($"rep.ps1 failed: exit={p.ExitCode}\n{stderr}"); - } - - return stdout; - } - - [Fact] - public void RepScript_Includes_InlineCode_And_Links_And_ImageText() - { - string output = RunRepScript(); - - output.Should().Contain("Inline code: Write-Host"); - output.Should().Contain("GitHub"); - output.Should().Contain("Blue styled link"); - output.Should().Contain("xkcd git"); - } -} diff --git a/PSTextMate.Tests/Integration/TaskListIntegrationTests.cs b/PSTextMate.Tests/Integration/TaskListIntegrationTests.cs deleted file mode 100644 index 65afe7c..0000000 --- a/PSTextMate.Tests/Integration/TaskListIntegrationTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using PwshSpectreConsole.TextMate.Core; -using System.Threading; -using TextMateSharp.Grammars; -using MarkdownRenderer = PwshSpectreConsole.TextMate.Core.Markdown.MarkdownRenderer; - -namespace PwshSpectreConsole.TextMate.Tests.Integration; - -/// -/// Integration tests to verify TaskList functionality works without reflection. -/// Tests the complete pipeline from markdown input to rendered output. -/// -public class TaskListIntegrationTests -{ - [Fact] - public void MarkdownRenderer_TaskList_ProducesCorrectCheckboxes() - { - // Arrange - var markdown = """ - # Task List Example - - - [x] Completed task - - [ ] Incomplete task - - [X] Another completed task - - Regular bullet point - """; - - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act - var result = MarkdownRenderer.Render(markdown, theme, themeName); - - // Assert - result.Should().NotBeNull(); - - // The result should be successfully rendered without reflection errors - // Since we can't easily inspect the internal structure, we verify that: - // 1. No exceptions are thrown (which would happen with reflection issues) - // 2. The result is not null - // 3. The array is not empty - result.Should().NotBeEmpty(); - - // In a real scenario, the TaskList items would be rendered with proper checkboxes - // The fact that this doesn't throw proves the reflection code was successfully removed - } - - [Theory] - [InlineData("- [x] Completed", true)] - [InlineData("- [ ] Incomplete", false)] - [InlineData("- [X] Uppercase completed", true)] - [InlineData("- Regular item", false)] - public void MarkdownRenderer_VariousTaskListFormats_RendersWithoutErrors(string markdown, bool isTaskList) - { - // Arrange - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act & Assert - Should not throw exceptions - var result = MarkdownRenderer.Render(markdown, theme, themeName); - - result.Should().NotBeNull(); - result.Should().NotBeEmpty(); - } - - [Fact] - public void MarkdownRenderer_ComplexTaskList_RendersWithoutReflectionErrors() - { - // Arrange - var markdown = """ - # Complex Task List - - 1. Ordered list with tasks: - - [x] Sub-task completed - - [ ] Sub-task incomplete - - - [x] Top-level completed - - [ ] Top-level incomplete - - [x] Nested completed - - [ ] Nested incomplete - - ## Another section - - Regular bullet - - Another bullet - """; - - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act & Assert - This would fail with reflection errors if not fixed - var result = MarkdownRenderer.Render(markdown, theme, themeName); - - result.Should().NotBeNull(); - result.Should().NotBeEmpty(); - - // Verify we have multiple rendered elements (headings, lists, etc.) - result.Should().HaveCountGreaterThan(3); - } - - [Fact] - public void StreamingProcessFileInBatches_ProducesMultipleBatchesWithOffsets() - { - // Arrange - create a temporary file with multiple lines that cross batch boundaries - string[] lines = Enumerable.Range(0, 2500).Select(i => i % 5 == 0 ? "// comment line" : "var x = 1; // code").ToArray(); - string temp = Path.GetTempFileName(); - File.WriteAllLines(temp, lines); - - try - { - // Act - var batches = TextMate.Core.TextMateProcessor.ProcessFileInBatches(temp, 1000, ThemeName.DarkPlus, ".cs", isExtension: true); - var batchList = batches.ToList(); - - // Assert - batchList.Should().NotBeEmpty(); - batchList.Count.Should().BeGreaterThan(1); - // Offsets should increase and cover the whole file - long covered = batchList.Sum(b => b.LineCount); - covered.Should().BeGreaterOrEqualTo(lines.Length); - // Batch indexes should be unique and sequential - batchList.Select(b => b.BatchIndex).Should().BeInAscendingOrder(); - } - finally - { - // Retry deletion a few times to avoid transient sharing violations on Windows - const int maxAttempts = 5; - for (int attempt = 0; attempt < maxAttempts; attempt++) - { - try - { - if (File.Exists(temp)) File.Delete(temp); - break; - } - catch (IOException) - { - Thread.Sleep(100); - } - } - } - } - - private static TextMateSharp.Themes.Theme CreateTestTheme() - { - var (_, theme) = TextMate.Infrastructure.CacheManager.GetCachedTheme(ThemeName.DarkPlus); - return theme; - } -} diff --git a/PSTextMate.Tests/Integration/TaskListReflectionRemovalTests.cs b/PSTextMate.Tests/Integration/TaskListReflectionRemovalTests.cs deleted file mode 100644 index b97df85..0000000 --- a/PSTextMate.Tests/Integration/TaskListReflectionRemovalTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using PwshSpectreConsole.TextMate.Core; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Integration; - -/// -/// Simple tests to verify that TaskList functionality works without reflection errors. -/// These tests use the public API to ensure the reflection code has been properly removed. -/// -public class TaskListReflectionRemovalTests -{ - [Fact] - public void TextMateProcessor_MarkdownWithTaskList_ProcessesWithoutReflectionErrors() - { - // Arrange - var markdown = """ - # Task List Test - - - [x] Completed task - - [ ] Incomplete task - - [X] Another completed task - """; - - // Act & Assert - This should not throw reflection-related exceptions - var exception = Record.Exception(() => - { - var result = TextMateProcessor.ProcessLinesCodeBlock( - lines: [markdown], - themeName: ThemeName.DarkPlus, - grammarId: "markdown", - isExtension: false); - - // Verify result is not null - result.Should().NotBeNull(); - }); // Assert - No exceptions should be thrown - exception.Should().BeNull("TaskList processing should work without reflection errors"); - } - - [Theory] - [InlineData("- [x] Completed task")] - [InlineData("- [ ] Incomplete task")] - [InlineData("- [X] Uppercase completed")] - [InlineData("- Regular bullet point")] - public void TextMateProcessor_VariousListFormats_ProcessesWithoutErrors(string listItem) - { - // Arrange - var lines = new[] { "# Test", "", listItem }; - - // Act & Assert - Should not throw any exceptions - var exception = Record.Exception(() => - { - var result = TextMateProcessor.ProcessLinesCodeBlock( - lines: lines, - themeName: ThemeName.DarkPlus, - grammarId: "markdown", - isExtension: false); - - result.Should().NotBeNull(); - }); exception.Should().BeNull($"Processing list item '{listItem}' should not throw exceptions"); - } - - [Fact] - public void TextMateProcessor_ComplexMarkdownWithNestedTaskLists_ProcessesSuccessfully() - { - // Arrange - var complexMarkdown = new[] - { - "# Complex Task List Example", - "", - "## Main Tasks", - "- [x] Setup project", - " - [x] Initialize repository", - " - [x] Add .gitignore", - " - [ ] Configure CI/CD", - "", - "## Development Tasks", - "1. [x] Write core functionality", - "2. [ ] Add comprehensive tests", - " - [x] Unit tests", - " - [ ] Integration tests", - "3. [ ] Documentation", - "", - "### Code Review Checklist", - "- [X] Code follows style guidelines", - "- [ ] Tests pass", - "- [ ] Documentation updated" - }; - - // Act & Assert - This complex structure should process without any reflection errors - var exception = Record.Exception(() => - { - var result = TextMateProcessor.ProcessLinesCodeBlock( - lines: complexMarkdown, - themeName: ThemeName.DarkPlus, - grammarId: "markdown", - isExtension: false); - - result.Should().NotBeNull(); - }); exception.Should().BeNull("Complex nested TaskList processing should work without reflection"); - } -} diff --git a/PSTextMate.Tests/PSTextMate.Tests.csproj b/PSTextMate.Tests/PSTextMate.Tests.csproj deleted file mode 100644 index df53a32..0000000 --- a/PSTextMate.Tests/PSTextMate.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net8.0 - enable - enable - false - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - diff --git a/PSTextMate.Tests/demo-textmate.ps1 b/PSTextMate.Tests/demo-textmate.ps1 deleted file mode 100644 index 37e168d..0000000 --- a/PSTextMate.Tests/demo-textmate.ps1 +++ /dev/null @@ -1,123 +0,0 @@ -#Requires -Modules 'PwshSpectreConsole' - -Set-SpectreColors -AccentColor BlueViolet - -# Build root layout scaffolding for: -# .--------------------------------. -# | Header | -# |--------------------------------| -# | File List | Preview | -# | | | -# | | | -# |___________|____________________| -$layout = New-SpectreLayout -Name "root" -Rows @( - # Row 1 - ( - New-SpectreLayout -Name "header" -MinimumSize 5 -Ratio 1 -Data ("empty") - ), - # Row 2 - ( - New-SpectreLayout -Name "content" -Ratio 10 -Columns @( - ( - New-SpectreLayout -Name "filelist" -Ratio 2 -Data "empty" - ), - ( - New-SpectreLayout -Name "preview" -Ratio 4 -Data "empty" - ) - ) - ) -) - -# Functions for rendering the content of each panel -function Get-TitlePanel { - return "📁 File Browser - Spectre Live Demo [gray]$(Get-Date)[/]" | Format-SpectreAligned -HorizontalAlignment Center -VerticalAlignment Middle | Format-SpectrePanel -Expand -} - -function Get-FileListPanel { - param ( - $Files, - $SelectedFile - ) - $fileList = $Files | ForEach-Object { - $name = $_.Name - if ($_.Name -eq $SelectedFile.Name) { - $name = "[darkviolet]$($name)[/]" - } - if ($_ -is [System.IO.DirectoryInfo]) { - "📁 $name" - } elseif ($_.Name -eq "..") { - "🔼 $name" - } else { - "📄 $name" - } - } | Out-String - return Format-SpectrePanel -Header "[white]File List[/]" -Data $fileList.Trim() -Expand -} - -function Get-PreviewPanel { - param ( - $SelectedFile - ) - $item = Get-Item -Path $SelectedFile.FullName - $result = "" - if ($item -is [System.IO.DirectoryInfo]) { - $result = "[grey]$($SelectedFile.Name) is a directory.[/]" - } else { - try { - # $content = Get-Content -Path $item.FullName -Raw -ErrorAction Stop - # $result = "[grey]$($content | Get-SpectreEscapedText)[/]" - $result = Show-TextMate -Path $item.FullName - } catch { - $result = "[red]Error reading file content: $($_.Exception.Message | Get-SpectreEscapedText)[/]" - } - } - return $result | Format-SpectrePanel -Header "[white]Preview[/]" -Expand -} - -# Start live rendering the layout -Invoke-SpectreLive -Data $layout -ScriptBlock { - param ( - $Context - ) - - # State - $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem) - $selectedFile = $fileList[0] - - while ($true) { - # Handle input - $lastKeyPressed = $null - while ([Console]::KeyAvailable) { - $lastKeyPressed = [Console]::ReadKey($true) - } - if ($lastKeyPressed -ne $null) { - if ($lastKeyPressed.Key -eq "DownArrow") { - $selectedFile = $fileList[($fileList.IndexOf($selectedFile) + 1) % $fileList.Count] - } elseif ($lastKeyPressed.Key -eq "UpArrow") { - $selectedFile = $fileList[($fileList.IndexOf($selectedFile) - 1 + $fileList.Count) % $fileList.Count] - } elseif ($lastKeyPressed.Key -eq "Enter") { - if ($selectedFile -is [System.IO.DirectoryInfo] -or $selectedFile.Name -eq "..") { - $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem -Path $selectedFile.FullName) - $selectedFile = $fileList[0] - } else { - notepad $selectedFile.FullName - return - } - } - } - - # Generate new data - $titlePanel = Get-TitlePanel - $fileListPanel = Get-FileListPanel -Files $fileList -SelectedFile $selectedFile - $previewPanel = Get-PreviewPanel -SelectedFile $selectedFile - - # Update layout - $layout["header"].Update($titlePanel) | Out-Null - $layout["filelist"].Update($fileListPanel) | Out-Null - $layout["preview"].Update($previewPanel) | Out-Null - - # Draw changes - $Context.Refresh() - Start-Sleep -Milliseconds 200 - } -} diff --git a/PSTextMate.Tests/line-test-1.md b/PSTextMate.Tests/line-test-1.md deleted file mode 100644 index 011d919..0000000 --- a/PSTextMate.Tests/line-test-1.md +++ /dev/null @@ -1,18 +0,0 @@ -# Simple Spacing Test - -Intro paragraph. -Second line of intro. - -## Section A - -Paragraph A line 1. -Paragraph A line 2. - -- Item 1 -- Item 2 - -## Section B - -Paragraph B after list. - -Trailing line after blank. diff --git a/PSTextMate.Tests/line-test-2.md b/PSTextMate.Tests/line-test-2.md deleted file mode 100644 index 6042fb5..0000000 --- a/PSTextMate.Tests/line-test-2.md +++ /dev/null @@ -1,18 +0,0 @@ -# Mixed Blocks Spacing - -Paragraph before code block. - -``` -code line 1 -code line 2 -``` - -> Quote line 1 -> Quote line 2 - -| Col1 | Col2 | -| ---- | ---- | -| A | 1 | -| B | 2 | - -End paragraph after table. diff --git a/PSTextMate.Tests/tests/Core/StandardRendererTests.cs b/PSTextMate.Tests/tests/Core/StandardRendererTests.cs deleted file mode 100644 index e69de29..0000000 diff --git a/PSTextMate.Tests/tests/Core/TextMateProcessorTests.cs b/PSTextMate.Tests/tests/Core/TextMateProcessorTests.cs deleted file mode 100644 index e69de29..0000000 diff --git a/PSTextMate.Tests/tests/Core/TokenProcessorTests.cs b/PSTextMate.Tests/tests/Core/TokenProcessorTests.cs deleted file mode 100644 index e69de29..0000000 diff --git a/PSTextMate.Tests/tests/Infrastructure/CacheManagerTests.cs b/PSTextMate.Tests/tests/Infrastructure/CacheManagerTests.cs deleted file mode 100644 index e69de29..0000000 diff --git a/_build.ps1 b/_build.ps1 deleted file mode 100644 index 332bd7c..0000000 --- a/_build.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -#Requires -Version 7.4 -if (-Not $PSScriptRoot) { - return 'Run this script from the root of the project' -} -$ErrorActionPreference = 'Stop' -Push-Location $PSScriptRoot - -dotnet clean -dotnet restore - -$ModuleFilesFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Module' -if (-Not (Test-Path $ModuleFilesFolder)) { - $null = New-Item -ItemType Directory -Path $ModuleFilesFolder -Force -} -Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -File -Recurse | Remove-Item -Force - -$moduleLibFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Output' | Join-Path -ChildPath 'lib' -if (-Not (Test-Path $moduleLibFolder)) { - $null = New-Item -ItemType Directory -Path $moduleLibFolder -Force -} - -$csproj = Get-Item (Join-Path -Path $PSScriptRoot -ChildPath 'src' | Join-Path -ChildPath 'PSTextMate.csproj') -$outputfolder = Join-Path -Path $PSScriptRoot -ChildPath 'packages' -if (-Not (Test-Path -Path $outputfolder)) { - $null = New-Item -ItemType Directory -Path $outputfolder -Force -} - -dotnet publish $csproj.FullName -c Release -o $outputfolder -Copy-Item -Path $ModuleFilesFolder/* -Destination (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -Force -Recurse -Include '*.psd1', '*.psm1', '*.ps1xml' - -Get-ChildItem -Path $moduleLibFolder -File | Remove-Item -Force - - -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'win-x64' | Join-Path -ChildPath 'native') -Filter *.dll | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'osx-arm64' | Join-Path -ChildPath 'native') -Filter *.dylib | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'linux-x64' | Join-Path -ChildPath 'native') -Filter *.so | Copy-Item -Destination $moduleLibFolder -Force -Move-Item (Join-Path -Path $outputfolder -ChildPath 'PSTextMate.dll') -Destination (Split-Path $moduleLibFolder) -Force -Get-ChildItem -Path $outputfolder -File | - Where-Object { -Not $_.Name.StartsWith('System.Text') -And $_.Extension -notin '.json','.pdb','.xml' } | - Move-Item -Destination $moduleLibFolder -Force - -Pop-Location diff --git a/tests/Format-CSharp.tests.ps1 b/tests/Format-CSharp.tests.ps1 index 145c18c..aee657b 100644 --- a/tests/Format-CSharp.tests.ps1 +++ b/tests/Format-CSharp.tests.ps1 @@ -1,3 +1,9 @@ +BeforeAll { + if (-Not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + Describe 'Format-CSharp' { It 'Formats a simple C# string and returns renderables' { $code = 'public class Foo { }' diff --git a/tests/Format-Markdown.tests.ps1 b/tests/Format-Markdown.tests.ps1 index e23883a..abbaae5 100644 --- a/tests/Format-Markdown.tests.ps1 +++ b/tests/Format-Markdown.tests.ps1 @@ -1,3 +1,9 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + Describe 'Format-Markdown' { It 'Formats Markdown and returns renderables' { $md = "# Title\n\nSome text" diff --git a/tests/Format-PowerShell.tests.ps1 b/tests/Format-PowerShell.tests.ps1 index f8c6c57..daa2ce2 100644 --- a/tests/Format-PowerShell.tests.ps1 +++ b/tests/Format-PowerShell.tests.ps1 @@ -1,3 +1,9 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + Describe 'Format-PowerShell' { It 'Formats a simple PowerShell string and returns renderables' { $ps = 'function Test-Thing { Write-Output "hi" }' diff --git a/tests/Get-SupportedTextMate.tests.ps1 b/tests/Get-SupportedTextMate.tests.ps1 index 744b413..216bf8a 100644 --- a/tests/Get-SupportedTextMate.tests.ps1 +++ b/tests/Get-SupportedTextMate.tests.ps1 @@ -1,3 +1,9 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + Describe 'Get-SupportedTextMate' { It 'Returns at least one available language' { $result = Get-SupportedTextMate diff --git a/tests/Show-TextMate.tests.ps1 b/tests/Show-TextMate.tests.ps1 index 00f8b00..a12b369 100644 --- a/tests/Show-TextMate.tests.ps1 +++ b/tests/Show-TextMate.tests.ps1 @@ -1,3 +1,9 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + BeforeAll { $psString = @' function Foo-Bar { diff --git a/tests/Test-SupportedTextMate.tests.ps1 b/tests/Test-SupportedTextMate.tests.ps1 index a6a1c68..8fef7b8 100644 --- a/tests/Test-SupportedTextMate.tests.ps1 +++ b/tests/Test-SupportedTextMate.tests.ps1 @@ -1,3 +1,9 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + Describe 'Test-SupportedTextMate' { It 'Recognizes powershell language' { Test-SupportedTextMate -Language 'powershell' | Should -BeTrue From 9fe93eca1a502b4a187688b068382630cef41f9c Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:21:05 +0100 Subject: [PATCH 20/25] playing around with grapheme --- Module/PSTextMate.format.ps1xml | 99 ++++ Module/PSTextMate.psd1 | 1 + TestStrings.ps1 | 25 + src/Cmdlets/MeasureStringCmdlet.cs | 47 ++ src/Utilities/Grapheme.cs | 801 +++++++++++++++++++++++++++++ 5 files changed, 973 insertions(+) create mode 100644 TestStrings.ps1 create mode 100644 src/Cmdlets/MeasureStringCmdlet.cs create mode 100644 src/Utilities/Grapheme.cs diff --git a/Module/PSTextMate.format.ps1xml b/Module/PSTextMate.format.ps1xml index f108d53..f797257 100644 --- a/Module/PSTextMate.format.ps1xml +++ b/Module/PSTextMate.format.ps1xml @@ -41,5 +41,104 @@ + + Grapheme + + PSTextMate.Helpers.Grapheme+GraphemeMeasurement + + + + + + + + + + + + + + + + + + + + + + + + + + + + StringLength + + + Cells + + + Length + + + + + + # HasWideCharacters + if ($_.HasWideCharacters) { + '{1}{0}{2}' -f ( + [char]0xf42e, + "$([char]27)[42m", + "$([char]27)[0m" + ) + } + else { + '{1}{0}{2}' -f ( + [char]0xEA87, + "$([char]27)[41m", + "$([char]27)[0m" + ) + } + + + + + + # ContainsVT + if ($null -eq $_.ContainsVT) { + return '{1}{0}{2}' -f ( + [Char]::ConvertFromUtf32(985058), + "$([char]27)[43m", + "$([char]27)[0m" + ) + } + if ($_.ContainsVT) { + '{1}{0}{2}' -f ( + [char]0xf42e, + "$([char]27)[42m", + "$([char]27)[0m" + ) + } + else { + '{1}{0}{2}' -f ( + [char]0xEA87, + "$([char]27)[41m", + "$([char]27)[0m" + ) + } + + + + Text + + + + + + diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index d474aef..e286dc6 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -14,6 +14,7 @@ 'Format-CSharp' 'Format-Markdown' 'Format-PowerShell' + 'Measure-String' ) AliasesToExport = @( 'fcs' diff --git a/TestStrings.ps1 b/TestStrings.ps1 new file mode 100644 index 0000000..cde2505 --- /dev/null +++ b/TestStrings.ps1 @@ -0,0 +1,25 @@ +$TestStrings = @( + 'Plain ASCII', + "CJK: `u{4E2D}`u{6587}`u{65E5}`u{672C}`u{8A9E}", + "Hangul: `u{D55C}`u{AE00}", + "Emoji: `u{1F600}`u{1F64F}`u{1F680}", + "Wide + ASCII: abc`u{4E2D}def`u{1F600}ghi", + "Combining: a`u{0301} e`u{0301} n`u{0303}", + "ZWJ: `u{1F469}`u{200D}`u{1F4BB}", + "Flag: `u{1F1FA}`u{1F1F8}" +) + +$AnsiTestStrings = @( + "`e[31mRed`e[0m", + "`e[32mGreen`e[0m `e[1mBold`e[0m", + "`e[38;5;214mIndexed`e[0m", + "`e[38;2;255;128;0mTrueColor`e[0m", + "VT + Wide: `e[36m`u{4E2D}`u{6587}`e[0m", + "OSC title: `e]0;PSTextMate Test`a", + "CSI cursor move: start`e[2Cend" +) + +$TestStrings | Measure-String +$AnsiTestStrings| Measure-String +# @([string[]][System.Text.Rune[]]@(0x1F600..0x1F64F)) | Measure-String +# @([string[]][char[]]@(@(0xe0b0..0xe0d4) + @(0x2588..0x259b) + @(0x256d..0x2572))) | Measure-String diff --git a/src/Cmdlets/MeasureStringCmdlet.cs b/src/Cmdlets/MeasureStringCmdlet.cs new file mode 100644 index 0000000..05acd2e --- /dev/null +++ b/src/Cmdlets/MeasureStringCmdlet.cs @@ -0,0 +1,47 @@ +using System.Management.Automation; +using PSTextMate.Helpers; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for measuring grapheme width and cursor movement for a string. +/// +[Cmdlet(VerbsDiagnostic.Measure, "String")] +[OutputType(typeof(Grapheme.GraphemeMeasurement), typeof(bool), typeof(int))] +public sealed class MeasureStringCmdlet : PSCmdlet { + /// + /// The input string to measure. + /// + [Parameter( + Mandatory = true, + Position = 0, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true + )] + [AllowEmptyString] + public string? InputString { get; set; } + + [Parameter] + public SwitchParameter IgnoreVT { get; set; } + + [Parameter] + public SwitchParameter IsWide { get; set; } + + [Parameter] + public SwitchParameter VisibleLength { get; set; } + protected override void ProcessRecord() { + if (InputString is null) { + return; + } + Grapheme.GraphemeMeasurement measurement = Grapheme.Measure(InputString, !IgnoreVT.IsPresent); + if (IsWide) { + WriteObject(measurement.HasWideCharacters); + return; + } + if (VisibleLength) { + WriteObject(measurement.Cells); + return; + } + WriteObject(measurement); + } +} diff --git a/src/Utilities/Grapheme.cs b/src/Utilities/Grapheme.cs new file mode 100644 index 0000000..2c3b698 --- /dev/null +++ b/src/Utilities/Grapheme.cs @@ -0,0 +1,801 @@ +using System.Runtime.CompilerServices; + +namespace PSTextMate.Helpers; + +// original from: https://gist.github.com/lhecker/cf12813c65684b6ff74f10dbf3672686 +// https://github.com/microsoft/terminal/blob/main/src/tools/GraphemeTableGen/Program.cs +// seems based on https://github.com/microsoft/terminal/blob/main/src/types/CodepointWidthDetector.cpp +// https://github.com/microsoft/terminal/blob/main/src/types/GlyphWidth.cpp + +public static class Grapheme { + + /// + /// grapheme measurement values. + /// + /// UTF-16 length of the input string. + /// Number of grapheme cursor movements. + /// Total column width of graphemes. + /// UTF-16 offset where measurement stopped. + public readonly struct GraphemeMeasurement + { + public string Text { get; } + public int StringLength => Text.Length; + /// CursorMovements + public int Cells { get; } + /// Columns + public int Length { get; } + public int EndOffset { get; } + public bool HasWideCharacters { get; } + public bool? ContainsVT { get; } + public GraphemeMeasurement(string input, int cells, int length, int endOffset, bool? containsVT) + { + Text = input; + Cells = cells; + Length = length; + EndOffset = endOffset; + ContainsVT = containsVT; + HasWideCharacters = length > cells; + } + public override string ToString() => $"StringLength: {StringLength}, Cells: {Cells}, Length: {Length}, Wide: {HasWideCharacters}, VT: {ContainsVT}"; + } + + /// + /// Measure a string and return combined grapheme results. + /// + /// + /// When true, VT escape sequences are ignored. + /// + /// + /// Combined grapheme measurement. + public static GraphemeMeasurement Measure(string input, bool useVT = false, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) + { + ArgumentNullException.ThrowIfNull(input); + + if (useVT) + { + MeasurementResult forward = MeasureForwardVT(input, 0, maxCursorMovements, maxColumns, out bool containsVT); + return new GraphemeMeasurement(input, forward.CursorMovements, forward.Columns, forward.Offset, containsVT); + } + + MeasurementResult nonVT = MeasureForward(input, 0, maxCursorMovements, maxColumns); + return new GraphemeMeasurement(input, nonVT.CursorMovements, nonVT.Columns, nonVT.Offset, null); + } + + /// + /// Returns true when the input contains any wide grapheme (column width 2). + /// + /// + /// When true, VT escape sequences are ignored. + /// True if a wide grapheme is present. + public static bool ContainsWide(string input, bool useVT = true) => + Measure(input, useVT).HasWideCharacters; + + /// + /// Returns the total visible column width for the input string. + /// + /// + /// When true, VT escape sequences are ignored. + /// Visible column width. + public static int VisibleStringLength(string input, bool useVT = true) => + Measure(input, useVT).Length; + + /// + /// Unicode tables + /// + private static readonly byte[] s_stage0 = [ + 0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x33, 0x33, 0x36, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x3c, 0x44, 0x4c, 0x4d, 0x4e, 0x48, 0x50, 0x58, 0x58, 0x58, 0x58, 0x5f, + 0x67, 0x6d, 0x75, 0x7d, 0x58, 0x58, 0x85, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x8b, 0x33, 0x33, + 0x93, 0x9b, 0x58, 0x58, 0x58, 0xa1, 0xa9, 0xad, 0x58, 0xb2, 0xba, 0xc0, 0xc8, 0xd0, 0xd8, 0xe0, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0xe8, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0xe8, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0xf0, 0xf2, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, + ]; + private static readonly ushort[] s_stage1 = [ + 0x0000, 0x000b, 0x000b, 0x001b, 0x0023, 0x002c, 0x003c, 0x004c, + 0x005c, 0x006c, 0x007c, 0x008c, 0x009c, 0x00ac, 0x00bc, 0x00cb, + 0x00d9, 0x00e9, 0x000b, 0x00f9, 0x000b, 0x000b, 0x000b, 0x0108, + 0x0118, 0x0126, 0x0135, 0x0145, 0x0155, 0x000f, 0x000b, 0x000b, + 0x0165, 0x0175, 0x000b, 0x0184, 0x0194, 0x01a1, 0x01b1, 0x01c1, + 0x000b, 0x01ce, 0x000b, 0x01de, 0x01e4, 0x01f4, 0x0204, 0x0214, + 0x0223, 0x0233, 0x0242, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, + 0x024c, 0x024c, 0x024c, 0x0250, 0x024c, 0x024c, 0x024c, 0x024c, + 0x0260, 0x000b, 0x026d, 0x000b, 0x027d, 0x028d, 0x029c, 0x02ac, + 0x02bc, 0x02be, 0x02c0, 0x02c2, 0x02bd, 0x02bf, 0x02c1, 0x02bc, + 0x02be, 0x02c0, 0x02c2, 0x02bd, 0x02bf, 0x02c1, 0x02bc, 0x02cc, + 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, + 0x024c, 0x024c, 0x02dc, 0x000b, 0x000b, 0x02ec, 0x02fc, 0x000b, + 0x030c, 0x031c, 0x032b, 0x000b, 0x000b, 0x000b, 0x000b, 0x033b, + 0x000b, 0x000b, 0x034a, 0x0350, 0x0360, 0x0370, 0x0380, 0x038e, + 0x039e, 0x03ab, 0x03b8, 0x03c6, 0x03d5, 0x03e3, 0x03f0, 0x0400, + 0x000b, 0x040e, 0x041b, 0x0425, 0x0435, 0x000b, 0x000b, 0x000b, + 0x000b, 0x0442, 0x000b, 0x000b, 0x000b, 0x0448, 0x0458, 0x000b, + 0x000b, 0x000b, 0x0464, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, + 0x024c, 0x024c, 0x0474, 0x024c, 0x024c, 0x024c, 0x024c, 0x0484, + 0x0494, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, + 0x0495, 0x024c, 0x04a5, 0x04ac, 0x000b, 0x000b, 0x000b, 0x000b, + 0x000b, 0x04bc, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, + 0x000b, 0x04cc, 0x000b, 0x04d6, 0x04e2, 0x000b, 0x000b, 0x000b, + 0x000b, 0x000b, 0x04f2, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, + 0x0502, 0x0458, 0x050b, 0x000b, 0x051a, 0x000b, 0x000b, 0x000b, + 0x0529, 0x0537, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, + 0x0547, 0x0557, 0x0567, 0x0577, 0x0587, 0x0597, 0x05a7, 0x05b7, + 0x05c7, 0x05d7, 0x05e7, 0x000b, 0x05f7, 0x05f7, 0x05f7, 0x05f8, + 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, 0x0608, + 0x0618, 0x0628, 0x0637, 0x0637, 0x0637, 0x0637, 0x0637, 0x0637, + 0x0637, 0x0637, + ]; + private static readonly ushort[] s_stage2 = [ + 0x0000, 0x0000, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0011, 0x0000, 0x0000, 0x0021, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, + 0x0031, 0x0031, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0041, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0030, 0x0031, 0x0051, 0x005f, 0x0010, 0x0010, 0x0010, 0x006f, 0x007f, 0x0010, 0x0010, + 0x008c, 0x0031, 0x0010, 0x009b, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x00a5, 0x00b4, 0x0010, 0x00c2, 0x00d2, 0x0010, 0x0031, + 0x00e2, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x004b, 0x009b, 0x0010, 0x0010, 0x008c, 0x00e9, 0x0010, 0x00f7, 0x0103, 0x0010, + 0x0010, 0x0111, 0x0010, 0x0010, 0x0010, 0x0121, 0x0010, 0x0010, 0x0075, 0x0031, 0x012f, 0x0031, 0x0098, 0x013f, 0x0144, 0x014a, + 0x0158, 0x0168, 0x0178, 0x0180, 0x0190, 0x013f, 0x01a0, 0x01af, 0x01bd, 0x01cb, 0x0178, 0x01da, 0x0190, 0x0010, 0x0010, 0x01dc, + 0x01ea, 0x00d2, 0x0010, 0x01f6, 0x0190, 0x013f, 0x01a0, 0x0206, 0x0214, 0x0010, 0x0178, 0x0222, 0x0190, 0x013f, 0x01a0, 0x0206, + 0x01bd, 0x0232, 0x0178, 0x0240, 0x024e, 0x0010, 0x0010, 0x009d, 0x025e, 0x0249, 0x0010, 0x0010, 0x0097, 0x013f, 0x01a0, 0x026e, + 0x027c, 0x028a, 0x0178, 0x0010, 0x0190, 0x0010, 0x0010, 0x01dc, 0x029a, 0x02a8, 0x0178, 0x024d, 0x0098, 0x013f, 0x0144, 0x02b8, + 0x02c6, 0x0249, 0x0178, 0x0010, 0x0190, 0x0010, 0x0010, 0x0010, 0x02d5, 0x02e4, 0x0010, 0x0178, 0x0010, 0x0010, 0x0010, 0x02f4, + 0x02ff, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x030e, 0x031b, 0x0010, 0x0010, 0x0010, 0x032a, 0x0010, 0x0335, 0x0010, + 0x0010, 0x0010, 0x0030, 0x0343, 0x0350, 0x0031, 0x0034, 0x024a, 0x0010, 0x0010, 0x0010, 0x0360, 0x036d, 0x0010, 0x037c, 0x038b, + 0x00bd, 0x0399, 0x03a9, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x03b9, 0x03b9, 0x03b9, 0x03b9, 0x03b9, 0x03b9, 0x03c9, + 0x03c9, 0x03c9, 0x03c9, 0x03d1, 0x03d9, 0x03d9, 0x03d9, 0x03d9, 0x03d9, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009e, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x03e9, 0x0010, 0x03f7, 0x0010, 0x0178, 0x0010, 0x0178, + 0x0010, 0x0010, 0x0010, 0x004d, 0x0031, 0x00e9, 0x0010, 0x0010, 0x03fc, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x02a8, 0x0010, 0x00ed, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0133, 0x0133, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0090, 0x0010, 0x0010, 0x0010, 0x040c, 0x041c, 0x0421, 0x0010, 0x0010, 0x0010, + 0x0031, 0x0032, 0x0010, 0x0010, 0x0010, 0x0097, 0x0010, 0x0010, 0x004d, 0x0097, 0x0010, 0x008c, 0x0098, 0x0099, 0x0010, 0x042f, + 0x0010, 0x0010, 0x0010, 0x004b, 0x0098, 0x0010, 0x0010, 0x004d, 0x00e5, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0154, 0x0434, 0x043d, 0x0447, 0x0010, 0x0457, 0x0466, 0x0469, 0x0010, 0x0479, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0031, 0x0031, 0x009b, 0x0010, 0x0010, 0x0489, 0x0469, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0495, 0x049f, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x04aa, 0x04b6, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x04c1, 0x0010, 0x0010, 0x0010, + 0x04ca, 0x0010, 0x04da, 0x04ea, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0489, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x04f5, 0x04c3, 0x04c9, 0x0010, 0x0010, + 0x0501, 0x0511, 0x051e, 0x0524, 0x0524, 0x052c, 0x0538, 0x0524, 0x0525, 0x0542, 0x0552, 0x0561, 0x056d, 0x0576, 0x0580, 0x0558, + 0x058e, 0x059c, 0x05a9, 0x05b5, 0x04fc, 0x05c1, 0x05d0, 0x05dd, 0x0010, 0x0010, 0x05e8, 0x04c8, 0x05ef, 0x0010, 0x0010, 0x0010, + 0x0010, 0x04a4, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x05ff, 0x0607, + 0x0010, 0x0010, 0x0010, 0x0613, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x009c, 0x009a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0031, 0x0031, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0623, 0x0629, 0x0623, 0x0623, + 0x0623, 0x0623, 0x0623, 0x0635, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0645, 0x0010, 0x0623, 0x0623, 0x0655, 0x0665, 0x0622, 0x0623, 0x0623, 0x0623, 0x0623, 0x0675, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0623, 0x0623, 0x061e, 0x0623, 0x0623, 0x0622, 0x0623, 0x0623, 0x0623, 0x0623, 0x0624, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0681, 0x0623, 0x0624, 0x0623, 0x0623, 0x0690, 0x0623, 0x0623, 0x0623, 0x0623, 0x06a0, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x06aa, 0x0623, 0x0623, 0x0623, 0x06b0, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x009c, 0x06c0, 0x0010, 0x009d, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009a, 0x06ce, 0x0010, 0x06db, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009a, 0x0010, 0x0010, 0x004d, 0x035a, 0x0010, 0x0031, 0x06eb, 0x0010, 0x0010, 0x06f4, + 0x0010, 0x0078, 0x0098, 0x03b9, 0x0704, 0x0098, 0x0010, 0x0010, 0x004e, 0x009b, 0x0010, 0x024b, 0x0010, 0x0010, 0x0076, 0x00e6, + 0x0711, 0x0010, 0x0010, 0x071f, 0x0010, 0x0010, 0x0010, 0x072f, 0x00d2, 0x0010, 0x008c, 0x02a8, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x073a, 0x0010, 0x074a, 0x074e, 0x075b, 0x0752, + 0x075b, 0x0756, 0x075b, 0x074a, 0x074e, 0x075b, 0x0752, 0x075b, 0x0756, 0x075b, 0x074a, 0x074e, 0x075b, 0x0752, 0x075b, 0x0756, + 0x075b, 0x074a, 0x074e, 0x075b, 0x0752, 0x075b, 0x0767, 0x03c9, 0x0777, 0x03d9, 0x03d9, 0x0782, 0x0010, 0x0242, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0792, 0x06ad, 0x0031, 0x0623, + 0x0623, 0x07a2, 0x07ab, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x07b7, 0x0622, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0623, 0x07b6, 0x0010, 0x0010, 0x07c7, 0x0010, 0x0010, 0x0010, 0x0010, 0x06b0, 0x07d7, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0243, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009b, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0091, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x07e6, 0x0010, 0x0010, 0x07f6, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x02a8, 0x0010, 0x0010, 0x07ee, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0806, 0x0010, 0x0010, 0x0010, 0x0010, 0x009e, + 0x0010, 0x0010, 0x0010, 0x0010, 0x004b, 0x009b, 0x0010, 0x0010, 0x03e9, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0099, 0x0010, 0x0010, 0x0077, 0x00e6, 0x0010, 0x0010, 0x0816, 0x0099, 0x0010, 0x0010, 0x0825, 0x0833, 0x0010, 0x0010, 0x0010, + 0x0099, 0x0010, 0x0078, 0x0097, 0x02a8, 0x0010, 0x0010, 0x024d, 0x0099, 0x0010, 0x0010, 0x004e, 0x0843, 0x0010, 0x0010, 0x0010, + 0x009f, 0x0851, 0x00d2, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x00e2, 0x0010, 0x0098, 0x0010, + 0x0010, 0x0860, 0x086e, 0x0249, 0x087c, 0x0097, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x004c, 0x00e6, + 0x0242, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0031, 0x0098, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x009c, 0x0428, 0x009b, 0x0889, 0x0010, 0x0010, 0x0010, 0x0031, 0x009b, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x008c, 0x00e5, 0x0010, 0x0010, 0x0010, 0x0010, 0x009e, 0x0899, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009f, 0x00e2, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x08a9, 0x08b7, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x08c6, 0x08d5, 0x0010, + 0x08e4, 0x0010, 0x0010, 0x08f1, 0x0249, 0x00e1, 0x0010, 0x0010, 0x0900, 0x00e3, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x009c, 0x090a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x004f, 0x0350, 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, 0x091a, 0x0929, + 0x0010, 0x0010, 0x0010, 0x008d, 0x0109, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0936, 0x0946, 0x0010, 0x0010, 0x0952, 0x0099, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0962, 0x004a, 0x035a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0097, 0x0010, 0x0010, 0x0010, 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x0030, 0x0031, 0x0031, 0x0972, 0x0099, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0982, 0x009a, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0623, 0x0623, 0x0690, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0645, 0x0010, 0x0010, 0x06ae, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0992, 0x0623, 0x0623, 0x07b4, 0x09a1, 0x0010, 0x09b1, 0x09bd, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x06ab, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x09c5, 0x0462, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0031, 0x0031, 0x0033, 0x0031, + 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x09d4, 0x09e1, 0x09ee, 0x0010, + 0x09fa, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x03f7, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0031, 0x0031, 0x0031, 0x089e, 0x0031, 0x0031, 0x0034, 0x024b, 0x024c, 0x008c, 0x0030, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x090a, 0x0425, 0x0a0a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0242, 0x0010, 0x0010, 0x0010, 0x009f, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009f, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, 0x031f, 0x0010, 0x0010, 0x0010, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0580, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, + 0x0524, 0x0524, 0x0524, 0x0525, 0x0524, 0x0524, 0x0524, 0x048c, 0x0010, 0x04ca, 0x0010, 0x0010, 0x0010, 0x048d, 0x0a1a, 0x05f0, + 0x0a2a, 0x048c, 0x0524, 0x0524, 0x0524, 0x0a3a, 0x0a40, 0x0a50, 0x0a60, 0x0a6b, 0x0a78, 0x0a88, 0x0522, 0x0536, 0x0524, 0x0524, + 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0a98, 0x0a98, 0x0aa7, 0x0ab4, 0x0a98, 0x0a98, 0x0a98, 0x0abb, 0x0a98, + 0x0538, 0x0a98, 0x0a98, 0x0ac9, 0x0538, 0x0a98, 0x0ad8, 0x0a98, 0x0a98, 0x0a98, 0x0a99, 0x0ae8, 0x0a98, 0x0a98, 0x0a98, 0x0a98, + 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0aeb, 0x0a98, 0x0a98, 0x0a98, 0x0afa, 0x0b08, 0x0a98, 0x0534, 0x0558, 0x0524, + 0x0566, 0x0580, 0x0524, 0x0524, 0x0524, 0x0524, 0x0529, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0010, 0x0010, 0x0010, 0x0a98, + 0x0a98, 0x0a98, 0x0a98, 0x0b18, 0x0b28, 0x056f, 0x0b30, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0b40, 0x0010, + 0x0010, 0x0010, 0x0010, 0x0010, 0x0b50, 0x0a9c, 0x0523, 0x048d, 0x0010, 0x0010, 0x0010, 0x0b60, 0x048f, 0x0010, 0x0010, 0x0b60, + 0x0010, 0x0b70, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0b80, 0x0a98, 0x0a98, 0x0b8c, 0x0b91, 0x0a98, 0x0a98, 0x0a98, 0x0a98, + 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0a9b, 0x0a9f, + 0x0a98, 0x0a98, 0x0b98, 0x0ba7, 0x0a9c, 0x0a9f, 0x0a9f, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, + 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0bb7, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, + 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0bc7, 0x0bd7, 0x0000, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, + 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + ]; + private static readonly byte[] s_stage3 = [ + 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x41, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x01, 0x4c, + 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x04, + 0x04, 0x04, 0x04, 0x04, 0x04, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x01, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x04, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, + 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x04, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x04, 0x04, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x04, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, + 0x4b, 0x4b, 0x4b, 0x4b, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x0a, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, + 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, + 0x40, 0x4b, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x0a, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x40, 0x4b, 0x4b, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x40, 0x4b, 0x4b, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, + 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, + 0x02, 0x0a, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, + 0x40, 0x4b, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, + 0x02, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x4b, 0x4b, + 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x0a, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, + 0x02, 0x40, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, + 0x4b, 0x4b, 0x4b, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, + 0x02, 0x02, 0x02, 0x0a, 0x44, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, + 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x40, 0x42, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, + 0x40, 0x42, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x00, 0x00, 0x00, + 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x02, 0x00, 0x02, 0x02, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x00, 0x00, 0x00, 0x02, 0x40, 0x40, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, + 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, + 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, + 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x01, 0x02, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x00, 0x02, 0x00, + 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x01, 0x02, 0x0d, 0x01, 0x01, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x41, + 0x41, 0x01, 0x01, 0x01, 0x01, 0x01, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x01, 0x01, 0x01, 0x01, 0x01, 0x41, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x80, + 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x8c, 0x40, 0x40, + 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, + 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x8c, 0x8c, + 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x40, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x8c, 0x8c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x8c, 0x40, 0x40, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x4c, 0x40, 0x4c, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, 0x8c, + 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, 0x40, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x8c, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x8c, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x02, 0x02, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x8c, 0x80, 0x8c, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, + 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x40, 0x40, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, + 0x85, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x02, 0x00, 0x40, 0x40, 0x02, + 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x40, 0x88, 0x89, 0x89, 0x89, 0x89, 0x89, + 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x88, 0x89, 0x89, 0x89, 0x89, 0x89, + 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x40, 0x40, + 0x40, 0x40, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x82, 0x80, 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x01, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x42, 0x42, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + 0x01, 0x01, 0x01, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, + 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x40, 0x40, 0x04, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x04, 0x40, 0x40, 0x02, 0x40, 0x44, 0x44, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, + 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, + 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x00, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x44, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x44, 0x02, 0x02, 0x02, 0x02, + 0x40, 0x40, 0x40, 0x40, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x44, + 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x44, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, + 0x02, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x02, 0x80, 0x80, 0x80, 0x80, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x80, + 0x80, 0x40, 0x40, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x80, 0x80, 0x80, 0x40, 0x40, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, + 0x40, 0x02, 0x02, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, + 0x80, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x8c, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x80, 0x4c, 0x4c, 0x4c, 0x4c, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, + 0x8c, 0x8c, 0x8c, 0x82, 0x82, 0x82, 0x82, 0x82, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, + 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, + 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, + 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, + 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x41, 0x01, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + ]; + private static readonly uint[][] s_joinRules = [ + [ + 0b00000011110011111111111111001111, + 0b00001111111111111111111111111111, + 0b00000011110011111111111111001111, + 0b00000011110011111111111101001111, + 0b00000000000000000000000000001100, + 0b00000011110000001100001111001111, + 0b00000011110011110000111111001111, + 0b00000011110011110011111111001111, + 0b00000011110011110000111111001111, + 0b00000011110011110011111111001111, + 0b00000011000011111111111111001111, + 0b00000011110011111111111111001111, + 0b00000011110011111111111111001111, + 0b00000000110011111111111111001111, + 0b00000000000000000000000000000000, + 0b00000000000000000000000000000000, + ], + [ + 0b00000011110011111111111111001111, + 0b00001111111111111111111111111111, + 0b00000011110011111111111111001111, + 0b00000011110011111111111111001111, + 0b00000000000000000000000000001100, + 0b00000011110000001100001111001111, + 0b00000011110011110000111111001111, + 0b00000011110011110011111111001111, + 0b00000011110011110000111111001111, + 0b00000011110011110011111111001111, + 0b00000011000011111111111111001111, + 0b00000011110011111111111111001111, + 0b00000011110011111111111111001111, + 0b00000000110011111111111111001111, + 0b00000000000000000000000000000000, + 0b00000000000000000000000000000000, + ], + ]; + /// + /// helper methods + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int UcdLookup(uint cp) + { + byte s0 = s_stage0[cp >> 11]; + ushort s1 = s_stage1[s0 + ((cp >> 8) & 7)]; + ushort s2 = s_stage2[s1 + ((cp >> 4) & 15)]; + return s_stage3[s2 + ((cp >> 0) & 15)]; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint UcdGraphemeJoins(uint state, int lead, int trail) + { + int l = lead & 15; + int t = trail & 15; + return (s_joinRules[state][l] >> (t * 2)) & 3; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool UcdGraphemeDone(uint state) => state == 3; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int UcdToCharacterWidth(int val) => val >> 6; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int Utf16NextOrFFFD(string str, int offset, out uint cp) + { + uint c = str[offset]; + offset++; + + // Is any surrogate? + if ((c & 0xF800) == 0xD800) + { + uint c1 = c; + c = 0xfffd; + + // Is leading surrogate and not at end? + if ((c1 & 0x400) == 0 && offset < str.Length) + { + char c2 = str[offset]; + // Is also trailing surrogate! + if ((c2 & 0xFC00) == 0xDC00) + { + c = (c1 << 10) - 0x35FDC00 + c2; + offset++; + } + } + } + + cp = c; + return offset; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TrySkipVTForward(string str, ref int offset) + { + int length = str.Length; + + if (offset >= length) + { + return false; + } + + char first = str[offset]; + int index = offset; + + switch (first) + { + case '\u001b': + if (index + 1 >= length) + { + offset = length; + return true; + } + + char escNext = str[index + 1]; + + if (escNext == '[') + { + index += 2; + SkipCsi(str, length, ref index); + offset = index; + return true; + } + + if (escNext is ']' or 'P' or '^' or '_') + { + index += 2; + SkipOscLike(str, length, ref index); + offset = index; + return true; + } + + offset = Math.Min(length, index + 2); + return true; + + case '\u009b': + index++; + SkipCsi(str, length, ref index); + offset = index; + return true; + + case '\u009d': + case '\u0090': + case '\u009e': + case '\u009f': + index++; + SkipOscLike(str, length, ref index); + offset = index; + return true; + + default: + return false; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SkipCsi(string str, int length, ref int index) + { + while (index < length) + { + char ch = str[index]; + + if (ch is >= (char)0x40 and <= (char)0x7e) + { + index++; + return; + } + + if (ch is < (char)0x20 or > (char)0x3f) + { + index++; + return; + } + + index++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SkipOscLike(string str, int length, ref int index) + { + while (index < length) + { + char ch = str[index]; + + if (ch == '\u0007') + { + index++; + return; + } + + if (ch == '\u001b' && index + 1 < length && str[index + 1] == '\\') + { + index += 2; + return; + } + + index++; + } + } + + /// + /// MeasureForward + /// + /// + /// + /// + /// + /// + public static MeasurementResult MeasureForward(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) + { + var cursorMovements = 0; + var columns = 0; + + if (offset < str.Length && maxCursorMovements > 0 && maxColumns > 0) + { + var offsetTrail = Utf16NextOrFFFD(str, offset, out var cp); + var lead = UcdLookup(cp); + + while (true) + { + var offsetLead = offsetTrail; + var width = 0; + uint state = 0; + + while (true) + { + width += UcdToCharacterWidth(lead); + + if (offsetTrail >= str.Length) + { + break; + } + + offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); + var trail = UcdLookup(cp); + state = UcdGraphemeJoins(state, lead, trail); + lead = trail; + + if (UcdGraphemeDone(state)) + { + break; + } + + offsetLead = offsetTrail; + } + + width = Math.Min(2, width); + + if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) + { + break; + } + + offset = offsetLead; + cursorMovements += 1; + columns += width; + + if (offset >= str.Length) + { + break; + } + } + } + + return new MeasurementResult(offset, cursorMovements, columns); + } + + /// + /// MeasureForward + /// + /// + /// + /// + /// + /// + public static MeasurementResult MeasureForwardVT(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) => + MeasureForwardVT(str, offset, maxCursorMovements, maxColumns, out _); + + private static MeasurementResult MeasureForwardVT(string str, int offset, int maxCursorMovements, int maxColumns, out bool containsVT) + { + int cursorMovements = 0; + int columns = 0; + bool sawVT = false; + + if (offset < str.Length && maxCursorMovements > 0 && maxColumns > 0) { + while (offset < str.Length && TrySkipVTForward(str, ref offset)) { + sawVT = true; + } + + if (offset >= str.Length) { + containsVT = sawVT; + return new MeasurementResult(offset, cursorMovements, columns); + } + + int offsetTrail = Utf16NextOrFFFD(str, offset, out uint cp); + int lead = UcdLookup(cp); + + do { + while (offsetTrail < str.Length && TrySkipVTForward(str, ref offsetTrail)) { + sawVT = true; + } + + int offsetLead = offsetTrail; + int width = 0; + uint state = 0; + + while (true) { + width += UcdToCharacterWidth(lead); + + if (offsetTrail >= str.Length) { + break; + } + + offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); + + while (offsetTrail < str.Length && TrySkipVTForward(str, ref offsetTrail)) { + sawVT = true; + } + + if (offsetTrail >= str.Length) { + break; + } + + int trail = UcdLookup(cp); + state = UcdGraphemeJoins(state, lead, trail); + lead = trail; + + if (UcdGraphemeDone(state)) { + break; + } + + offsetLead = offsetTrail; + } + + width = Math.Min(2, width); + + if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { + break; + } + + offset = offsetLead; + cursorMovements++; + columns += width; + } + while (offset < str.Length); + } + + containsVT = sawVT; + return new MeasurementResult(offset, cursorMovements, columns); + } + public readonly record struct MeasurementResult(int Offset, int CursorMovements, int Columns) { } + +} From 2508a1e12309456b476bb6791167185bbafb4726 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:49:26 +0100 Subject: [PATCH 21/25] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20Clean=20up=20a?= =?UTF-8?q?nd=20optimize=20code=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Removed unused variables and redundant code in `TextMateCmdletBase`. * Simplified conditional statements in `HighlightedText` and `StandardRenderer`. * Consolidated logic in `Grapheme` for better readability. * Added new test script `TestMeasure.ps1` for measuring string properties. * Replaced old test script with a new `harness.ps1` for improved loading. --- test.ps1 => harness.ps1 | 0 src/Cmdlets/TextMateCmdletBase.cs | 30 ++--- src/Core/HighlightedText.cs | 30 ++--- src/Core/StandardRenderer.cs | 3 +- src/Core/TextMateProcessor.cs | 3 +- src/Core/TokenProcessor.cs | 9 +- src/Rendering/ImageRenderer.cs | 10 +- src/Rendering/ParagraphRenderer.cs | 2 +- src/Utilities/Grapheme.cs | 138 ++++++++--------------- TestStrings.ps1 => tools/TestMeasure.ps1 | 0 10 files changed, 81 insertions(+), 144 deletions(-) rename test.ps1 => harness.ps1 (100%) rename TestStrings.ps1 => tools/TestMeasure.ps1 (100%) diff --git a/test.ps1 b/harness.ps1 similarity index 100% rename from test.ps1 rename to harness.ps1 diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs index e1ae352..e3c9a5e 100644 --- a/src/Cmdlets/TextMateCmdletBase.cs +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -12,8 +12,6 @@ namespace PSTextMate.Commands; /// public abstract class TextMateCmdletBase : PSCmdlet { private readonly List _inputObjectBuffer = []; - private string? _sourceBaseDirectory; - private string? _sourceExtensionHint; /// /// String content or file path to render with syntax highlighting. @@ -80,12 +78,12 @@ public abstract class TextMateCmdletBase : PSCmdlet { /// /// Resolved extension hint from the pipeline input. /// - protected string? SourceExtensionHint => _sourceExtensionHint; + protected string? SourceExtensionHint { get; private set; } /// /// Resolved base directory for markdown rendering. /// - protected string? SourceBaseDirectory => _sourceBaseDirectory; + protected string? SourceBaseDirectory { get; private set; } protected override void ProcessRecord() { if (MyInvocation.ExpectingInput) { @@ -93,7 +91,7 @@ protected override void ProcessRecord() { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); + WriteObject(result); } } catch (Exception ex) { @@ -117,7 +115,7 @@ protected override void ProcessRecord() { try { foreach (HighlightedText result in ProcessPathInput(file)) { - WriteObject(result); + WriteObject(result); } } catch (Exception ex) { @@ -138,7 +136,7 @@ protected override void EndProcessing() { HighlightedText? result = ProcessStringInput(); if (result is not null) { - WriteObject(result); + WriteObject(result); } } catch (Exception ex) { @@ -190,13 +188,9 @@ private IEnumerable ProcessPathInput(FileInfo filePath) { } } - protected virtual (string token, bool asExtension) ResolveTokenForStringInput() { - return ResolveFixedToken(); - } + protected virtual (string token, bool asExtension) ResolveTokenForStringInput() => ResolveFixedToken(); - protected virtual (string token, bool asExtension) ResolveTokenForPathInput(FileInfo filePath) { - return ResolveFixedToken(); - } + protected virtual (string token, bool asExtension) ResolveTokenForPathInput(FileInfo filePath) => ResolveFixedToken(); protected (string token, bool asExtension) ResolveFixedToken() { if (!FixedTokenIsExtension) { @@ -212,7 +206,7 @@ private void EnsureSourceHints() { return; } - if (_sourceBaseDirectory is not null && _sourceExtensionHint is not null) { + if (SourceBaseDirectory is not null && SourceExtensionHint is not null) { return; } @@ -223,7 +217,7 @@ private void EnsureSourceHints() { return; } - if (_sourceExtensionHint is null) { + if (SourceExtensionHint is null) { string ext = Path.GetExtension(hint); if (string.IsNullOrWhiteSpace(ext)) { string resolvedHint = GetUnresolvedProviderPathFromPSPath(hint); @@ -231,16 +225,16 @@ private void EnsureSourceHints() { } if (!string.IsNullOrWhiteSpace(ext)) { - _sourceExtensionHint = ext; + SourceExtensionHint = ext; WriteVerbose($"Detected extension hint from input: {ext}"); } } - if (_sourceBaseDirectory is null) { + if (SourceBaseDirectory is null) { string resolvedPath = GetUnresolvedProviderPathFromPSPath(hint); string? baseDir = Path.GetDirectoryName(resolvedPath); if (!string.IsNullOrWhiteSpace(baseDir)) { - _sourceBaseDirectory = baseDir; + SourceBaseDirectory = baseDir; Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; WriteVerbose($"Set markdown base directory from input: {baseDir}"); } diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs index d8bc63a..0dc5c28 100644 --- a/src/Core/HighlightedText.cs +++ b/src/Core/HighlightedText.cs @@ -1,7 +1,7 @@ -using Spectre.Console; -using Spectre.Console.Rendering; using System.Globalization; using System.Linq; +using Spectre.Console; +using Spectre.Console.Rendering; namespace PSTextMate.Core; @@ -48,11 +48,7 @@ protected override IEnumerable Render(RenderOptions options, int maxWid // Delegate to Rows which efficiently renders all renderables var rows = new Rows(Renderables); - if (!ShowLineNumbers) { - return ((IRenderable)rows).Render(options, maxWidth); - } - - return RenderWithLineNumbers(rows, options, maxWidth); + return !ShowLineNumbers ? ((IRenderable)rows).Render(options, maxWidth) : RenderWithLineNumbers(rows, options, maxWidth); } /// @@ -62,11 +58,7 @@ protected override Measurement Measure(RenderOptions options, int maxWidth) { // Delegate to Rows for measurement var rows = new Rows(Renderables); - if (!ShowLineNumbers) { - return ((IRenderable)rows).Measure(options, maxWidth); - } - - return MeasureWithLineNumbers(rows, options, maxWidth); + return !ShowLineNumbers ? ((IRenderable)rows).Measure(options, maxWidth) : MeasureWithLineNumbers(rows, options, maxWidth); } private IEnumerable RenderWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { @@ -84,14 +76,14 @@ private Measurement MeasureWithLineNumbers(Rows rows, RenderOptions options, int private (List segments, int width, int contentWidth) RenderInnerSegments(Rows rows, RenderOptions options, int maxWidth) { int width = ResolveLineNumberWidth(LineCount); int contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); - List segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); + var segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); int actualLineCount = CountLines(segments); int actualWidth = ResolveLineNumberWidth(actualLineCount); if (actualWidth != width) { width = actualWidth; contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); - segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); + segments = [.. ((IRenderable)rows).Render(options, contentWidth)]; } return (segments, width, contentWidth); @@ -116,13 +108,13 @@ private IEnumerable PrefixLineNumbers(List segments, RenderOpt } private static IEnumerable> SplitLines(IEnumerable segments) { - List current = new(); + List current = []; bool sawLineBreak = false; foreach (Segment segment in segments) { if (segment.IsLineBreak) { yield return current; - current = new List(); + current = []; sawLineBreak = true; continue; } @@ -143,11 +135,7 @@ private static int CountLines(List segments) { } int lineBreaks = segments.Count(segment => segment.IsLineBreak); - if (lineBreaks == 0) { - return 1; - } - - return segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; + return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; } private int ResolveLineNumberWidth(int lineCount) { diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index e2dbf15..c24e5f4 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -28,8 +28,7 @@ public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar try { IStateStack? ruleStack = null; for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { - if (string.IsNullOrEmpty(lines[lineIndex])) - { + if (string.IsNullOrEmpty(lines[lineIndex])) { rows.Add(new Rows(Text.Empty)); continue; } diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 5822220..f5a8fd9 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -100,8 +100,7 @@ private static IRenderable[] RenderCodeBlock(string[] lines, Theme theme, IGramm List rows = new(lines.Length); IStateStack? ruleStack = null; for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { - if (String.IsNullOrEmpty(lines[lineIndex])) - { + if (string.IsNullOrEmpty(lines[lineIndex])) { rows.Add(new Rows(Text.Empty)); continue; } diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs index 24149e2..8427a00 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -175,14 +175,12 @@ public static void ProcessTokensToParagraph( /// /// Returns a cached Style for the given scopes and theme. Returns null for default/no-style. /// - public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) - { + public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { string scopesKey = string.Join('\u001F', scopes); int themeHash = RuntimeHelpers.GetHashCode(theme); (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); - if (_styleCache.TryGetValue(cacheKey, out Style? cached)) - { + if (_styleCache.TryGetValue(cacheKey, out Style? cached)) { return cached; } @@ -190,8 +188,7 @@ public static void ProcessTokensToParagraph( // Create a dummy token-like enumerable for existing ExtractThemeProperties method var token = new MarkdownToken([.. scopes]); (int fg, int bg, FontStyle fs) = ExtractThemeProperties(token, theme); - if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) - { + if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) { _styleCache.TryAdd(cacheKey, null); return null; } diff --git a/src/Rendering/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs index a6178dc..7882813 100644 --- a/src/Rendering/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -153,11 +153,9 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int /// Attempts to create a sixel renderable using the newest available implementation. /// private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { - if (TryCreatePixelImage(imagePath, maxWidth, out result)) { - return true; - } - - return TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); + return TryCreatePixelImage(imagePath, maxWidth, out result) + ? true + : TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); } /// @@ -167,7 +165,7 @@ private static bool TryCreatePixelImage(string imagePath, int? maxWidth, out IRe result = null; try { - Type? pixelImageType = Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); + var pixelImageType = Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); if (pixelImageType is null) { return false; } diff --git a/src/Rendering/ParagraphRenderer.cs b/src/Rendering/ParagraphRenderer.cs index d005526..2892d70 100644 --- a/src/Rendering/ParagraphRenderer.cs +++ b/src/Rendering/ParagraphRenderer.cs @@ -46,7 +46,7 @@ public static IEnumerable Render(ParagraphBlock paragraph, Theme th /// Accumulates plain text and flushes when style changes (code, links). /// private static void BuildTextSegments(List segments, ContainerInline inlines, Theme theme, bool skipLineBreaks = false, bool splitOnLineBreaks = false) { - Paragraph paragraph = new Paragraph(); + var paragraph = new Paragraph(); bool addedAny = false; List inlineList = [.. inlines]; diff --git a/src/Utilities/Grapheme.cs b/src/Utilities/Grapheme.cs index 2c3b698..842889f 100644 --- a/src/Utilities/Grapheme.cs +++ b/src/Utilities/Grapheme.cs @@ -10,14 +10,15 @@ namespace PSTextMate.Helpers; public static class Grapheme { /// - /// grapheme measurement values. + /// grapheme measurement /// - /// UTF-16 length of the input string. - /// Number of grapheme cursor movements. - /// Total column width of graphemes. + /// Input string. + /// UTF-16 length of the input string. + /// Number of grapheme cursor movements. + /// Total column width of graphemes. /// UTF-16 offset where measurement stopped. - public readonly struct GraphemeMeasurement - { + /// String contains VT/OSC/CSI. + public readonly struct GraphemeMeasurement { public string Text { get; } public int StringLength => Text.Length; /// CursorMovements @@ -27,8 +28,7 @@ public readonly struct GraphemeMeasurement public int EndOffset { get; } public bool HasWideCharacters { get; } public bool? ContainsVT { get; } - public GraphemeMeasurement(string input, int cells, int length, int endOffset, bool? containsVT) - { + public GraphemeMeasurement(string input, int cells, int length, int endOffset, bool? containsVT) { Text = input; Cells = cells; Length = length; @@ -47,12 +47,10 @@ public GraphemeMeasurement(string input, int cells, int length, int endOffset, b /// /// /// Combined grapheme measurement. - public static GraphemeMeasurement Measure(string input, bool useVT = false, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) - { + public static GraphemeMeasurement Measure(string input, bool useVT = true, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { ArgumentNullException.ThrowIfNull(input); - if (useVT) - { + if (useVT) { MeasurementResult forward = MeasureForwardVT(input, 0, maxCursorMovements, maxColumns, out bool containsVT); return new GraphemeMeasurement(input, forward.CursorMovements, forward.Columns, forward.Offset, containsVT); } @@ -76,8 +74,8 @@ public static bool ContainsWide(string input, bool useVT = true) => /// /// When true, VT escape sequences are ignored. /// Visible column width. - public static int VisibleStringLength(string input, bool useVT = true) => - Measure(input, useVT).Length; + public static int VisibleLength(string input, bool useVT = true) => + Measure(input, useVT).Cells; /// /// Unicode tables @@ -490,16 +488,14 @@ public static int VisibleStringLength(string input, bool useVT = true) => /// helper methods /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int UcdLookup(uint cp) - { + private static int UcdLookup(uint cp) { byte s0 = s_stage0[cp >> 11]; ushort s1 = s_stage1[s0 + ((cp >> 8) & 7)]; ushort s2 = s_stage2[s1 + ((cp >> 4) & 15)]; return s_stage3[s2 + ((cp >> 0) & 15)]; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static uint UcdGraphemeJoins(uint state, int lead, int trail) - { + private static uint UcdGraphemeJoins(uint state, int lead, int trail) { int l = lead & 15; int t = trail & 15; return (s_joinRules[state][l] >> (t * 2)) & 3; @@ -509,24 +505,20 @@ private static uint UcdGraphemeJoins(uint state, int lead, int trail) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int UcdToCharacterWidth(int val) => val >> 6; [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int Utf16NextOrFFFD(string str, int offset, out uint cp) - { + private static int Utf16NextOrFFFD(string str, int offset, out uint cp) { uint c = str[offset]; offset++; // Is any surrogate? - if ((c & 0xF800) == 0xD800) - { + if ((c & 0xF800) == 0xD800) { uint c1 = c; c = 0xfffd; // Is leading surrogate and not at end? - if ((c1 & 0x400) == 0 && offset < str.Length) - { + if ((c1 & 0x400) == 0 && offset < str.Length) { char c2 = str[offset]; // Is also trailing surrogate! - if ((c2 & 0xFC00) == 0xDC00) - { + if ((c2 & 0xFC00) == 0xDC00) { c = (c1 << 10) - 0x35FDC00 + c2; offset++; } @@ -538,39 +530,31 @@ private static int Utf16NextOrFFFD(string str, int offset, out uint cp) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TrySkipVTForward(string str, ref int offset) - { + private static bool TrySkipVTForward(string str, ref int offset) { int length = str.Length; - if (offset >= length) - { + if (offset >= length) { return false; } char first = str[offset]; int index = offset; - switch (first) - { + switch (first) { case '\u001b': - if (index + 1 >= length) - { + if (index + 1 >= length) { offset = length; return true; } - char escNext = str[index + 1]; - - if (escNext == '[') - { + if (str[index + 1] == '[') { index += 2; SkipCsi(str, length, ref index); offset = index; return true; } - if (escNext is ']' or 'P' or '^' or '_') - { + if (str[index + 1] is ']' or 'P' or '^' or '_') { index += 2; SkipOscLike(str, length, ref index); offset = index; @@ -601,20 +585,14 @@ private static bool TrySkipVTForward(string str, ref int offset) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SkipCsi(string str, int length, ref int index) - { - while (index < length) - { - char ch = str[index]; - - if (ch is >= (char)0x40 and <= (char)0x7e) - { + private static void SkipCsi(string str, int length, ref int index) { + while (index < length) { + if (str[index] is >= (char)0x40 and <= (char)0x7e) { index++; return; } - if (ch is < (char)0x20 or > (char)0x3f) - { + if (str[index] is < (char)0x20 or > (char)0x3f) { index++; return; } @@ -624,24 +602,17 @@ private static void SkipCsi(string str, int length, ref int index) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SkipOscLike(string str, int length, ref int index) - { - while (index < length) - { - char ch = str[index]; - - if (ch == '\u0007') - { + private static void SkipOscLike(string str, int length, ref int index) { + while (index < length) { + if (str[index] == '\u0007') { index++; return; } - if (ch == '\u001b' && index + 1 < length && str[index + 1] == '\\') - { + if (str[index] == '\u001b' && index + 1 < length && str[index + 1] == '\\') { index += 2; return; } - index++; } } @@ -654,38 +625,32 @@ private static void SkipOscLike(string str, int length, ref int index) /// /// /// - public static MeasurementResult MeasureForward(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) - { - var cursorMovements = 0; - var columns = 0; - - if (offset < str.Length && maxCursorMovements > 0 && maxColumns > 0) - { - var offsetTrail = Utf16NextOrFFFD(str, offset, out var cp); - var lead = UcdLookup(cp); - - while (true) - { - var offsetLead = offsetTrail; - var width = 0; + public static MeasurementResult MeasureForward(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { + int cursorMovements = 0; + int columns = 0; + + if (offset < str.Length && maxCursorMovements > 0 && maxColumns > 0) { + int offsetTrail = Utf16NextOrFFFD(str, offset, out uint cp); + int lead = UcdLookup(cp); + + while (true) { + int offsetLead = offsetTrail; + int width = 0; uint state = 0; - while (true) - { + while (true) { width += UcdToCharacterWidth(lead); - if (offsetTrail >= str.Length) - { + if (offsetTrail >= str.Length) { break; } offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); - var trail = UcdLookup(cp); + int trail = UcdLookup(cp); state = UcdGraphemeJoins(state, lead, trail); lead = trail; - if (UcdGraphemeDone(state)) - { + if (UcdGraphemeDone(state)) { break; } @@ -694,8 +659,7 @@ public static MeasurementResult MeasureForward(string str, int offset = 0, int m width = Math.Min(2, width); - if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) - { + if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { break; } @@ -703,8 +667,7 @@ public static MeasurementResult MeasureForward(string str, int offset = 0, int m cursorMovements += 1; columns += width; - if (offset >= str.Length) - { + if (offset >= str.Length) { break; } } @@ -724,8 +687,7 @@ public static MeasurementResult MeasureForward(string str, int offset = 0, int m public static MeasurementResult MeasureForwardVT(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) => MeasureForwardVT(str, offset, maxCursorMovements, maxColumns, out _); - private static MeasurementResult MeasureForwardVT(string str, int offset, int maxCursorMovements, int maxColumns, out bool containsVT) - { + private static MeasurementResult MeasureForwardVT(string str, int offset, int maxCursorMovements, int maxColumns, out bool containsVT) { int cursorMovements = 0; int columns = 0; bool sawVT = false; diff --git a/TestStrings.ps1 b/tools/TestMeasure.ps1 similarity index 100% rename from TestStrings.ps1 rename to tools/TestMeasure.ps1 From 8e39f4af6df704016700c5de3c7aefa9c5eafc27 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Sun, 8 Feb 2026 02:47:03 +0100 Subject: [PATCH 22/25] more measure string shenanigans --- Module/PSTextMate.format.ps1xml | 3 +- src/Cmdlets/MeasureStringCmdlet.cs | 36 ++-- src/Rendering/ImageRenderer.cs | 7 +- src/Utilities/Grapheme.cs | 255 +++++++++++++++++++++++++---- tools/TestMeasure.ps1 | 11 ++ 5 files changed, 260 insertions(+), 52 deletions(-) diff --git a/Module/PSTextMate.format.ps1xml b/Module/PSTextMate.format.ps1xml index f797257..f7f1298 100644 --- a/Module/PSTextMate.format.ps1xml +++ b/Module/PSTextMate.format.ps1xml @@ -44,12 +44,13 @@ Grapheme - PSTextMate.Helpers.Grapheme+GraphemeMeasurement + PSTextMate.Helpers.GraphemeMeasurement + Center diff --git a/src/Cmdlets/MeasureStringCmdlet.cs b/src/Cmdlets/MeasureStringCmdlet.cs index 05acd2e..64f06f8 100644 --- a/src/Cmdlets/MeasureStringCmdlet.cs +++ b/src/Cmdlets/MeasureStringCmdlet.cs @@ -1,4 +1,5 @@ using System.Management.Automation; +using System.Runtime.InteropServices; using PSTextMate.Helpers; namespace PSTextMate.Commands; @@ -6,8 +7,8 @@ namespace PSTextMate.Commands; /// /// Cmdlet for measuring grapheme width and cursor movement for a string. /// -[Cmdlet(VerbsDiagnostic.Measure, "String")] -[OutputType(typeof(Grapheme.GraphemeMeasurement), typeof(bool), typeof(int))] +[Cmdlet(VerbsDiagnostic.Measure, "String", DefaultParameterSetName = "Default")] +[OutputType(typeof(GraphemeMeasurement), typeof(bool), typeof(int))] public sealed class MeasureStringCmdlet : PSCmdlet { /// /// The input string to measure. @@ -24,24 +25,33 @@ public sealed class MeasureStringCmdlet : PSCmdlet { [Parameter] public SwitchParameter IgnoreVT { get; set; } - [Parameter] + [Parameter( + ParameterSetName = "Wide" + )] public SwitchParameter IsWide { get; set; } - [Parameter] + [Parameter( + ParameterSetName = "Visible" + )] public SwitchParameter VisibleLength { get; set; } protected override void ProcessRecord() { if (InputString is null) { return; } - Grapheme.GraphemeMeasurement measurement = Grapheme.Measure(InputString, !IgnoreVT.IsPresent); - if (IsWide) { - WriteObject(measurement.HasWideCharacters); - return; - } - if (VisibleLength) { - WriteObject(measurement.Cells); - return; + GraphemeMeasurement measurement = Grapheme.Measure(InputString, !IgnoreVT.IsPresent); + switch (ParameterSetName) { + case "Wide": { + WriteObject(measurement.HasWideCharacters); + break; + } + case "Visible": { + WriteObject(measurement.Cells); + break; + } + default: { + WriteObject(measurement); + break; + } } - WriteObject(measurement); } } diff --git a/src/Rendering/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs index 7882813..036319c 100644 --- a/src/Rendering/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -152,11 +152,8 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int /// /// Attempts to create a sixel renderable using the newest available implementation. /// - private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { - return TryCreatePixelImage(imagePath, maxWidth, out result) - ? true - : TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); - } + private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) + => TryCreatePixelImage(imagePath, maxWidth, out result) || TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); /// /// Attempts to create a PixelImage from PwshSpectreConsole using reflection. diff --git a/src/Utilities/Grapheme.cs b/src/Utilities/Grapheme.cs index 842889f..1f86853 100644 --- a/src/Utilities/Grapheme.cs +++ b/src/Utilities/Grapheme.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using PSTextMate.Helpers; namespace PSTextMate.Helpers; @@ -7,38 +8,36 @@ namespace PSTextMate.Helpers; // seems based on https://github.com/microsoft/terminal/blob/main/src/types/CodepointWidthDetector.cpp // https://github.com/microsoft/terminal/blob/main/src/types/GlyphWidth.cpp -public static class Grapheme { - - /// - /// grapheme measurement - /// - /// Input string. - /// UTF-16 length of the input string. - /// Number of grapheme cursor movements. - /// Total column width of graphemes. - /// UTF-16 offset where measurement stopped. - /// String contains VT/OSC/CSI. - public readonly struct GraphemeMeasurement { - public string Text { get; } - public int StringLength => Text.Length; - /// CursorMovements - public int Cells { get; } - /// Columns - public int Length { get; } - public int EndOffset { get; } - public bool HasWideCharacters { get; } - public bool? ContainsVT { get; } - public GraphemeMeasurement(string input, int cells, int length, int endOffset, bool? containsVT) { - Text = input; - Cells = cells; - Length = length; - EndOffset = endOffset; - ContainsVT = containsVT; - HasWideCharacters = length > cells; - } - public override string ToString() => $"StringLength: {StringLength}, Cells: {Cells}, Length: {Length}, Wide: {HasWideCharacters}, VT: {ContainsVT}"; +/// +/// grapheme measurement +/// +/// Input string. +/// UTF-16 length of the input string. +/// Number of grapheme cursor movements. +/// Total column width of graphemes. +/// UTF-16 offset where measurement stopped. +/// String contains VT/OSC/CSI. +public readonly struct GraphemeMeasurement { + public string Text { get; } + public int StringLength => Text.Length; + /// CursorMovements + public int Cells { get; } + /// Columns + public int Length { get; } + public int EndOffset { get; } + public bool HasWideCharacters { get; } + public bool? ContainsVT { get; } + public GraphemeMeasurement(string input, int cells, int length, int endOffset, bool? containsVT) { + Text = input; + Cells = cells; + Length = length; + EndOffset = endOffset; + ContainsVT = containsVT; + HasWideCharacters = length > cells; } - + public override string ToString() => $"StringLength: {StringLength}, Cells: {Cells}, Length: {Length}, Wide: {HasWideCharacters}, VT: {ContainsVT}"; +} +public static class Grapheme { /// /// Measure a string and return combined grapheme results. /// @@ -529,6 +528,30 @@ private static int Utf16NextOrFFFD(string str, int offset, out uint cp) { return offset; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int Utf16PrevOrFFFD(string str, int offset, out uint cp) { + offset--; + uint c = str[offset]; + + // Is any surrogate? + if ((c & 0xF800) == 0xD800) { + uint c2 = c; + c = 0xfffd; + + // Is trailing surrogate and not at begin? + if ((c2 & 0x400) != 0 && offset != 0) { + uint c1 = str[offset - 1]; + // Is also leading surrogate! + if ((c1 & 0xFC00) == 0xD800) { + c = (c1 << 10) - 0x35FDC00 + c2; + offset--; + } + } + } + + cp = c; + return offset; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TrySkipVTForward(string str, ref int offset) { int length = str.Length; @@ -584,6 +607,34 @@ private static bool TrySkipVTForward(string str, ref int offset) { } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool TrySkipVTBackward(string str, ref int offset) { + int length = str.Length; + + if (offset <= 0 || offset > length) { + return false; + } + + int scan = offset; + + while (scan > 0) { + int start = scan - 1; + char c = str[start]; + + if (c is '\u001b' or (>= '\u0090' and <= '\u009f')) { + int forward = start; + if (TrySkipVTForward(str, ref forward) && forward >= offset) { + offset = start; + return true; + } + } + + scan = start; + } + + return false; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void SkipCsi(string str, int length, ref int index) { while (index < length) { @@ -706,6 +757,7 @@ private static MeasurementResult MeasureForwardVT(string str, int offset, int ma int lead = UcdLookup(cp); do { + // Skip VT sequences before reading the next codepoint to keep state aligned. while (offsetTrail < str.Length && TrySkipVTForward(str, ref offsetTrail)) { sawVT = true; } @@ -721,8 +773,6 @@ private static MeasurementResult MeasureForwardVT(string str, int offset, int ma break; } - offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); - while (offsetTrail < str.Length && TrySkipVTForward(str, ref offsetTrail)) { sawVT = true; } @@ -731,6 +781,8 @@ private static MeasurementResult MeasureForwardVT(string str, int offset, int ma break; } + offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); + int trail = UcdLookup(cp); state = UcdGraphemeJoins(state, lead, trail); lead = trail; @@ -758,6 +810,143 @@ private static MeasurementResult MeasureForwardVT(string str, int offset, int ma containsVT = sawVT; return new MeasurementResult(offset, cursorMovements, columns); } + + /// + /// MeasureBackward + /// + /// + /// + /// + /// + /// + public static MeasurementResult MeasureBackward(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { + int cursorMovements = 0; + int columns = 0; + + if (offset > 0 && maxCursorMovements > 0 && maxColumns > 0) { + int offsetLead = Utf16PrevOrFFFD(str, offset, out uint cp); + int trail = UcdLookup(cp); + + while (true) { + int offsetTrail = offsetLead; + int width = 0; + uint state = 0; + + while (true) { + width += UcdToCharacterWidth(trail); + + if (offsetLead <= 0) { + break; + } + + offsetLead = Utf16PrevOrFFFD(str, offsetLead, out cp); + int lead = UcdLookup(cp); + state = UcdGraphemeJoins(state, lead, trail); + trail = lead; + + if (UcdGraphemeDone(state)) { + break; + } + + offsetTrail = offsetLead; + } + + width = Math.Min(2, width); + + if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { + break; + } + + offset = offsetTrail; + cursorMovements += 1; + columns += width; + + if (offset <= 0) { + break; + } + } + } + + return new MeasurementResult(offset, cursorMovements, columns); + } + + /// + /// MeasureBackward + /// + /// + /// + /// + /// + /// + public static MeasurementResult MeasureBackwardVT(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { + int cursorMovements = 0; + int columns = 0; + + if (offset > 0 && maxCursorMovements > 0 && maxColumns > 0) { + while (offset > 0 && TrySkipVTBackward(str, ref offset)) { + } + + if (offset <= 0) { + return new MeasurementResult(offset, cursorMovements, columns); + } + + int offsetLead = Utf16PrevOrFFFD(str, offset, out uint cp); + int trail = UcdLookup(cp); + + do { + int offsetTrail = offsetLead; + int width = 0; + uint state = 0; + + while (true) { + width += UcdToCharacterWidth(trail); + + if (offsetLead <= 0) { + break; + } + + while (offsetLead > 0 && TrySkipVTBackward(str, ref offsetLead)) { + } + + if (offsetLead <= 0) { + break; + } + + offsetLead = Utf16PrevOrFFFD(str, offsetLead, out cp); + int lead = UcdLookup(cp); + state = UcdGraphemeJoins(state, lead, trail); + trail = lead; + + if (UcdGraphemeDone(state)) { + break; + } + + offsetTrail = offsetLead; + } + + width = Math.Min(2, width); + + if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { + break; + } + + offset = offsetTrail; + cursorMovements++; + columns += width; + + while (offset > 0 && TrySkipVTBackward(str, ref offset)) { + } + + if (offset > 0) { + offsetLead = Utf16PrevOrFFFD(str, offset, out cp); + trail = UcdLookup(cp); + } + } + while (offset > 0); + } + + return new MeasurementResult(offset, cursorMovements, columns); + } public readonly record struct MeasurementResult(int Offset, int CursorMovements, int Columns) { } } diff --git a/tools/TestMeasure.ps1 b/tools/TestMeasure.ps1 index cde2505..226f34e 100644 --- a/tools/TestMeasure.ps1 +++ b/tools/TestMeasure.ps1 @@ -1,3 +1,9 @@ +if (-not (Get-Module PSTextMate)) { + # Import-Module (Join-Path $PSScriptRoot 'output' 'PSTextMate.psd1') + $Path = Resolve-Path (Join-Path $PSScriptRoot '..') + . (Join-Path $Path 'harness.ps1') -Load -Path $Path +} + $TestStrings = @( 'Plain ASCII', "CJK: `u{4E2D}`u{6587}`u{65E5}`u{672C}`u{8A9E}", @@ -17,9 +23,14 @@ $AnsiTestStrings = @( "VT + Wide: `e[36m`u{4E2D}`u{6587}`e[0m", "OSC title: `e]0;PSTextMate Test`a", "CSI cursor move: start`e[2Cend" + '{0}{1}First - {2}{3}Second - {4}{5}{6}Bold' -f $PSStyle.Foreground.Red, $PSStyle.Background.Green, $PSStyle.Foreground.Green, $psstyle.Background.Red, $PSStyle.Blink, $PSStyle.Background.Yellow, $PSStyle.Foreground.BrightCyan + '{0}Hello{1}{2}{3}{1}yep!' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.FormatHyperlink('world!', 'https://www.example.com') + '{0}Hello{1}{2}{3} https://www.example.com' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.Reset ) $TestStrings | Measure-String +$TestStrings | Measure-String -IgnoreVT $AnsiTestStrings| Measure-String +# $AnsiTestStrings| Measure-String -IgnoreVT # @([string[]][System.Text.Rune[]]@(0x1F600..0x1F64F)) | Measure-String # @([string[]][char[]]@(@(0xe0b0..0xe0d4) + @(0x2588..0x259b) + @(0x256d..0x2572))) | Measure-String From 593bacdb971b5f6e1ba52f119953ad85fcee5c8c Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:15:56 +0100 Subject: [PATCH 23/25] mm --- Module/PSTextMate.format.ps1xml | 10 +++++----- tools/TestMeasure.ps1 | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Module/PSTextMate.format.ps1xml b/Module/PSTextMate.format.ps1xml index f7f1298..506549d 100644 --- a/Module/PSTextMate.format.ps1xml +++ b/Module/PSTextMate.format.ps1xml @@ -93,14 +93,14 @@ if ($_.HasWideCharacters) { '{1}{0}{2}' -f ( [char]0xf42e, - "$([char]27)[42m", + "$([char]27)[32m", "$([char]27)[0m" ) } else { '{1}{0}{2}' -f ( [char]0xEA87, - "$([char]27)[41m", + "$([char]27)[31m", "$([char]27)[0m" ) } @@ -113,21 +113,21 @@ if ($null -eq $_.ContainsVT) { return '{1}{0}{2}' -f ( [Char]::ConvertFromUtf32(985058), - "$([char]27)[43m", + "$([char]27)[33m", "$([char]27)[0m" ) } if ($_.ContainsVT) { '{1}{0}{2}' -f ( [char]0xf42e, - "$([char]27)[42m", + "$([char]27)[32m", "$([char]27)[0m" ) } else { '{1}{0}{2}' -f ( [char]0xEA87, - "$([char]27)[41m", + "$([char]27)[31m", "$([char]27)[0m" ) } diff --git a/tools/TestMeasure.ps1 b/tools/TestMeasure.ps1 index 226f34e..7e8cb12 100644 --- a/tools/TestMeasure.ps1 +++ b/tools/TestMeasure.ps1 @@ -24,12 +24,11 @@ $AnsiTestStrings = @( "OSC title: `e]0;PSTextMate Test`a", "CSI cursor move: start`e[2Cend" '{0}{1}First - {2}{3}Second - {4}{5}{6}Bold' -f $PSStyle.Foreground.Red, $PSStyle.Background.Green, $PSStyle.Foreground.Green, $psstyle.Background.Red, $PSStyle.Blink, $PSStyle.Background.Yellow, $PSStyle.Foreground.BrightCyan - '{0}Hello{1}{2}{3}{1}yep!' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.FormatHyperlink('world!', 'https://www.example.com') + '{0}Hello{1}{2}{3}{1}{4}yep!' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.FormatHyperlink('world!', 'https://www.example.com'), [Char]::ConvertFromUtf32(128110) '{0}Hello{1}{2}{3} https://www.example.com' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.Reset ) $TestStrings | Measure-String -$TestStrings | Measure-String -IgnoreVT $AnsiTestStrings| Measure-String # $AnsiTestStrings| Measure-String -IgnoreVT # @([string[]][System.Text.Rune[]]@(0x1F600..0x1F64F)) | Measure-String From 31dc29f0f9042bcaef135c1a51a8647f67e344d2 Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:27:51 +0100 Subject: [PATCH 24/25] =?UTF-8?q?feat(docs):=20=E2=9C=8F=EF=B8=8F=20Add=20?= =?UTF-8?q?documentation=20for=20TextMate=20formatting=20cmdlets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `Format-CSharp`, `Format-Markdown`, and `Format-PowerShell` cmdlets for syntax highlighting and formatting of respective code snippets. - Added `Get-SupportedTextMate` cmdlet to retrieve supported TextMate languages and grammar metadata. - Implemented `Show-TextMate` for displaying syntax-highlighted text using TextMate grammars. - Created `Test-SupportedTextMate` to verify support for languages, extensions, or files. - Each cmdlet includes detailed examples, parameters, and usage notes to enhance user understanding. --- .gitignore | 1 + Module/PSTextMate.psd1 | 12 +- PSTextMate.build.ps1 | 51 +- README.md | 143 ++-- docs/en-us/Format-CSharp.md | 159 +++++ docs/en-us/Format-Markdown.md | 180 +++++ docs/en-us/Format-PowerShell.md | 155 +++++ docs/en-us/Get-SupportedTextMate.md | 84 +++ docs/en-us/Show-TextMate.md | 201 ++++++ docs/en-us/Test-SupportedTextMate.md | 164 +++++ src/Cmdlets/MeasureStringCmdlet.cs | 57 -- src/Cmdlets/ShowTextMateCmdlet.cs | 2 +- src/Cmdlets/TestSupportedTextMate.cs | 3 +- src/PSTextMate.csproj | 17 +- src/Utilities/Grapheme.cs | 952 --------------------------- 15 files changed, 1054 insertions(+), 1127 deletions(-) create mode 100644 docs/en-us/Format-CSharp.md create mode 100644 docs/en-us/Format-Markdown.md create mode 100644 docs/en-us/Format-PowerShell.md create mode 100644 docs/en-us/Get-SupportedTextMate.md create mode 100644 docs/en-us/Show-TextMate.md create mode 100644 docs/en-us/Test-SupportedTextMate.md delete mode 100644 src/Cmdlets/MeasureStringCmdlet.cs delete mode 100644 src/Utilities/Grapheme.cs diff --git a/.gitignore b/.gitignore index 1c7b30a..ec061ea 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ debug.md .github/prompts/* ref/** Copilot-Processing.md +tools/** diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index e286dc6..a9b8b9c 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -1,5 +1,5 @@ @{ - RootModule = 'PSTextMate.dll' + RootModule = 'lib/PSTextMate.dll' ModuleVersion = '0.1.0' GUID = 'a6490f8a-1f53-44f2-899c-bf66b9c6e608' Author = 'trackd' @@ -14,21 +14,15 @@ 'Format-CSharp' 'Format-Markdown' 'Format-PowerShell' - 'Measure-String' ) AliasesToExport = @( 'fcs' 'fmd' 'fps' - 'st' + 'stm' 'Show-Code' ) - RequiredAssemblies = @( - 'lib/Onigwrap.dll' - 'lib/TextMateSharp.dll' - 'lib/TextMateSharp.Grammars.dll' - 'Markdig.Signed.dll' - ) + RequiredAssemblies = @() FormatsToProcess = 'PSTextMate.format.ps1xml' RequiredModules = @( @{ diff --git a/PSTextMate.build.ps1 b/PSTextMate.build.ps1 index 8d2b251..b21b1ab 100644 --- a/PSTextMate.build.ps1 +++ b/PSTextMate.build.ps1 @@ -29,32 +29,24 @@ task Clean { New-Item -Path $folders.OutputPath -ItemType Directory -Force | Out-Null } + task Build { if (-not (Test-Path $folders.CsprojPath)) { Write-Warning 'C# project not found, skipping Build' return } - try { - Push-Location $folders.SourcePath - - # exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.DestinationPath } - exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.TempLib } - if ($LASTEXITCODE -ne 0) { - throw "Build failed with exit code $LASTEXITCODE" - } - New-Item -Path $folders.outputPath -ItemType Directory -Force | Out-Null - New-Item -Path $folders.DestinationPath -ItemType Directory -Force | Out-Null - Get-ChildItem -Path (Join-Path $folders.TempLib 'runtimes' 'win-x64' 'native') -Filter *.dll | Move-Item -Destination $folders.DestinationPath -Force - Get-ChildItem -Path (Join-Path $folders.TempLib 'runtimes' 'osx-arm64' 'native') -Filter *.dylib | Move-Item -Destination $folders.DestinationPath -Force - Get-ChildItem -Path (Join-Path $folders.TempLib 'runtimes' 'linux-x64' 'native') -Filter *.so | Copy-Item -Destination $folders.DestinationPath -Force - Move-Item (Join-Path $folders.TempLib 'PSTextMate.dll') -Destination $folders.OutputPath -Force - Get-ChildItem "$($folders.TempLib)/*.dll" | Move-Item -Destination $folders.DestinationPath -Force - if (Test-Path -Path $folders.TempLib -PathType Container) { - Remove-Item -Path $folders.TempLib -Recurse -Force -ErrorAction 'Ignore' - } + exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.TempLib } + $null = New-Item -Path $folders.outputPath -ItemType Directory -Force + $rids = @('win-x64', 'osx-arm64', 'linux-x64','linux-arm64','win-arm64') + foreach ($rid in $rids) { + $ridDest = Join-Path $folders.DestinationPath $rid + $null = New-Item -Path $ridDest -ItemType Directory -Force + $nativePath = Join-Path $folders.TempLib 'runtimes' $rid 'native' + Get-ChildItem -Path $nativePath -File | Move-Item -Destination $ridDest -Force } - finally { - Pop-Location + Get-ChildItem -Path $folders.TempLib -File | Move-Item -Destination $folders.DestinationPath -Force + if (Test-Path -Path $folders.TempLib -PathType Container) { + Remove-Item -Path $folders.TempLib -Recurse -Force -ErrorAction 'Ignore' } } @@ -85,6 +77,12 @@ task GenerateHelp -if (-not $SkipHelp) { return } + if (-Not (Get-Module PwshSpectreConsole -ListAvailable)) { + # just temporarily while im refactoring the PwshSpectreConsole module. + $ParentPath = Split-Path $folders.ProjectRoot -Parent + Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + } + Import-Module $modulePath -Force $helpOutputPath = Join-Path $folders.OutputPath 'en-US' @@ -111,8 +109,12 @@ task Test -if (-not $SkipTests) { Write-Warning "Test directory not found at: $($folders.TestPath)" return } - $ParentPath = Split-Path $folders.ProjectRoot -Parent - Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + + if (-not (Get-Module PwshSpectreConsole -ListAvailable)) { + # just temporarily while im refactoring the PwshSpectreConsole module. + $ParentPath = Split-Path $folders.ProjectRoot -Parent + Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + } Import-Module (Join-Path $folders.OutputPath 'PSTextMate.psd1') -ErrorAction Stop Import-Module (Join-Path $folders.TestPath 'testhelper.psm1') -ErrorAction Stop @@ -124,9 +126,10 @@ task Test -if (-not $SkipTests) { $pesterConfig.Debug.WriteDebugMessages = $false Invoke-Pester -Configuration $pesterConfig } + task CleanAfter { - if ($script:config.DestinationPath -and (Test-Path $script:config.DestinationPath)) { - Get-ChildItem $script:config.DestinationPath -File | Where-Object { $_.Extension -in '.pdb', '.json' } | Remove-Item -Force -ErrorAction Ignore + if ($script:folders.DestinationPath -and (Test-Path $script:folders.DestinationPath)) { + Get-ChildItem $script:folders.DestinationPath -File -Recurse | Where-Object { $_.Extension -in '.pdb', '.json' } | Remove-Item -Force -ErrorAction Ignore } } diff --git a/README.md b/README.md index 7008e55..76cf62f 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,77 @@ # PSTextMate -This modules allows you to render content in the console using TextMate. -see below table for supported grammars and builtin themes. - -the module is meant to be paired with PwshSpectreConsole. - -this is still in alpha stage, so expect breaking changes. - -todo: add custom grammars, add custom themes. - -## libraries - -[TextMateSharp](https://github.com/danipen/TextMateSharp) -[PwshSpectreConsole](https://github.com/ShaunLawrie/PwshSpectreConsole) -[SpectreConsole](https://github.com/spectreconsole/spectre.console) - -## Grammars and Themes - -| grammars | themes | -|---------------|-------------------| -| asciidoc | Abbys | -| bat | Dark | -| clojure | DarkPlus | -| coffeescript | DimmedMonokai | -| cpp | KimbieDark | -| csharp | Light | -| css | LightPlus | -| dart | Monokai | -| diff | QuietLight | -| docker | Red | -| fsharp | SolarizedDark | -| git | SolarizedLight | -| go | TomorrowNightBlue | -| groovy | HighContrastLight | -| handlebars | HighContrastDark | -| hlsl | | -| html | | -| ini | | -| java | | -| javascript | | -| json | | -| julia | | -| latex | | -| less | | -| log | | -| lua | | -| make | | -| markdown | | -| markdown-math | | -| objective-c | | -| pascal | | -| perl | | -| php | | -| powershell | | -| pug | | -| python | | -| r | | -| razor | | -| ruby | | -| rust | | -| scss | | -| shaderlab | | -| shellscript | | -| sql | | -| swift | | -| typescript | | -| vb | | -| xml | | -| yaml | | +PSTextMate delivers syntax-aware highlighting for PowerShell on top of TextMate grammars. It exposes a focused set of cmdlets that emit tokenized, theme-styled `HighlightedText` renderables you can write with PwshSpectreConsole or feed into any Spectre-based pipeline. Helper cmdlets make it easy to discover grammars and validate support for files, extensions, or language IDs before formatting. + +What it does + +- Highlights source text using TextMate grammars such as PowerShell, C#, Markdown, and Python. +- Returns `HighlightedText` renderables that implement Spectre.Console's contract, so they can be written through PwshSpectreConsole or other Spectre hosts. +- Provides discovery and testing helpers for installed grammars, extensions, or language IDs. +- Does inline Sixel images in markdown + +## Cmdlets + +| Cmdlet | Purpose | +|--------|---------| +| [Format-CSharp](docs/en-us/Format-CSharp.md) | Highlight C# source | +| [Format-Markdown](docs/en-us/Format-Markdown.md) | Highlight Markdown content | +| [Format-PowerShell](docs/en-us/Format-PowerShell.md) | Highlight PowerShell code | +| [Show-TextMate](docs/en-us/Show-TextMate.md) | Render text with an inferred or explicit language. | +| [Get-SupportedTextMate](docs/en-us/Get-SupportedTextMate.md) | List available grammars and file extensions. | +| [Test-SupportedTextMate](docs/en-us/Test-SupportedTextMate.md) | Check support for a file, extension, or language ID. | + +## Examples + +```powershell +# highlight a C# snippet +"public class C { void M() {} }" | Format-CSharp + +# render a Markdown file with a theme +Get-Content README.md -Raw | Format-Markdown -Theme SolarizedLight + +# list supported grammars +Get-SupportedTextMate +``` + +## Installation + +```powershell +Install-Module PSTextMate +``` + +### Prerequisites + +- **PowerShell**: 7.4 + +### Building from Source + +1. Clone this repository +2. Open a terminal in the project directory +3. Build the project: + +```powershell +& .\build.ps1 +``` + +1. Import the module: + +```powershell +Import-Module .\output\PSTextMate.psd1 +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Update documentation as needed +5. Submit a pull request + +## Dependencies + +- [TextMateSharp](https://github.com/danipen/TextMateSharp) + - [OnigWrap](https://github.com/aikawayataro/Onigwrap) +- [PwshSpectreConsole](https://github.com/ShaunLawrie/PwshSpectreConsole) + - [SpectreConsole](https://github.com/spectreconsole/spectre.console) + +--- diff --git a/docs/en-us/Format-CSharp.md b/docs/en-us/Format-CSharp.md new file mode 100644 index 0000000..fbcaa1d --- /dev/null +++ b/docs/en-us/Format-CSharp.md @@ -0,0 +1,159 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Format-CSharp +--- + +# Format-CSharp + +## SYNOPSIS + +Renders C# source code using TextMate grammars and returns a PSTextMate.Core.HighlightedText object. +Use for previewing or formatting C# snippets and files. + +## SYNTAX + +### (All) + +``` +Format-CSharp [-InputObject] [-Theme ] [-LineNumbers] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + fcs + +## DESCRIPTION + +Format-CSharp renders C# input using the TextMate grammar for C#. Input can be provided as objects (strings) via the pipeline or by passing file contents. +The cmdlet produces a `HighlightedText` object suitable for rendering to console. +Use `-Theme` to select a visual theme and `-LineNumbers` to include line numbers in the output. + +## EXAMPLES + +### Example 1 + +Example: highlight a C# snippet from the pipeline + +``` +"public class C { void M() {} }" | Format-CSharp +``` + +### Example 2 + +Example: format a file and include line numbers + +``` +Get-Content .\src\Program.cs -Raw | Format-CSharp -LineNumbers +``` + +### Example 3 + +Example: Pipe FileInfo objects + +``` +Get-ChildItem *.cs | Format-CSharp -Theme SolarizedDark +``` + +## PARAMETERS + +### -InputObject + +Accepts a string or object containing source code. +When receiving pipeline input, the cmdlet treats the value as source text. +For file processing, pass the file contents (for example with `Get-Content -Raw`). +FileInfo objects are also accepted + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +When specified, include line numbers in the rendered output to aid reference and diffs. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Selects a `TextMateSharp.Grammars.ThemeName` to use when rendering. If omitted, the module default theme is used. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts any object that can be converted to or contains a code string; commonly a `string` produced by `Get-Content -Raw` or piped literal text. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns a `HighlightedText` object which contains the tokenized and styled representation of the input. This object is intended for rendering to consoles or for downstream processing. + +## NOTES + +This cmdlet uses TextMate grammars packaged with the module. For large files consider streaming the contents or increasing process memory limits. + +## RELATED LINKS + +See also `Format-PowerShell` and `Format-Markdown` for other language renderers. diff --git a/docs/en-us/Format-Markdown.md b/docs/en-us/Format-Markdown.md new file mode 100644 index 0000000..d051776 --- /dev/null +++ b/docs/en-us/Format-Markdown.md @@ -0,0 +1,180 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Format-Markdown +--- + +# Format-Markdown + +## SYNOPSIS + +Renders Markdown input using TextMate grammars or the module's alternate renderer and returns a PSTextMate.Core.HighlightedText object. + +## SYNTAX + +### (All) + +``` +Format-Markdown [-InputObject] [-Alternate] [-Theme ] [-LineNumbers] + [] +``` + +## ALIASES + +This cmdlet has the following aliases, + fmd + +## DESCRIPTION + +Format-Markdown highlights Markdown content using the Markdown grammar where appropriate. +Use the `-Alternate` switch to force TextMate renderer as opposed to custom Markdig rendering. +Input may be piped in as text or read from files. +The cmdlet returns a `HighlightedText` object for rendering. + +## EXAMPLES + +### Example 1 + +Example: highlight a Markdown string + +``` +"# Title`n`n- item1`n- item2" | Format-Markdown +``` + +### Example 2 + +Example: format a file using the alternate renderer + +``` +Get-Content README.md -Raw | Format-Markdown -Alternate +``` + +### Example 3 + +Example: pipe FileInfo object and use a theme and line numbers + +``` +Get-ChildItem docs\guide.md | Format-Markdown -Theme SolarizedLight -LineNumbers +``` + +## PARAMETERS + +### -InputObject + +Accepts a string or object containing Markdown text. Common usage is `Get-Content -Raw` piped into the cmdlet. +FileInfo objects are also accepted + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +Includes line numbers in the rendered output when specified. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Selects a `TextMateSharp.Grammars.ThemeName` to style the output. If omitted, the module default is used. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Alternate + +When present, forces the module's TextMate rendering instead of the custom Markdig rendering path. +Useful for testing alternate presentation. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts Markdown text as a `string` or an object that can provide textual content. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns a `HighlightedText` object representing the highlighted Markdown content. + +## NOTES + +The Markdown renderer may apply special handling for fenced code blocks and front-matter when `UsesMarkdownBaseDirectory` is enabled. Use `-Alternate` to bypass markdown-specific rendering. + +## RELATED LINKS + +See `Format-CSharp` and `Format-PowerShell` for language-specific renderers. diff --git a/docs/en-us/Format-PowerShell.md b/docs/en-us/Format-PowerShell.md new file mode 100644 index 0000000..29a6b89 --- /dev/null +++ b/docs/en-us/Format-PowerShell.md @@ -0,0 +1,155 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Format-PowerShell +--- + +# Format-PowerShell + +## SYNOPSIS + +Renders PowerShell code using TextMate grammars and returns a PSTextMate.Core.HighlightedText result for display or programmatic use. + +## SYNTAX + +### (All) + +``` +Format-PowerShell [-InputObject] [-Theme ] [-LineNumbers] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + fps + +## DESCRIPTION + +Format-PowerShell highlights PowerShell source and script files. Input can be provided as pipeline text or via file contents. The resulting `HighlightedText` can be used with console renderers or further processed. Use `-Theme` and `-LineNumbers` to adjust output. + +## EXAMPLES + +### Example 1 + +Example: highlight a short PowerShell snippet + +``` +'Get-Process | Where-Object { $_.CPU -gt 1 }' | Format-PowerShell +``` + +### Example 2 + +Example: highlight a script file with line numbers + +``` +Get-Content .\scripts\deploy.ps1 -Raw | Format-PowerShell -LineNumbers +``` + +### Example 3 + +Example: Pipe FileInfo object and render with theme. + +``` +Get-ChildItem .\scripts\*.ps1 | Format-PowerShell -Theme Monokai +``` + +## PARAMETERS + +### -InputObject + +Accepts a `string` or object containing PowerShell source text. +Typically used with `Get-Content -Raw` or piping literal strings. +Accepts FileInfo objects + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +When present, include line numbers in the rendered output. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Chooses a `TextMateSharp.Grammars.ThemeName` for styling the highlighted output. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.Management.Automation.PSObject + +Accepts textual input representing PowerShell source. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns the highlighted representation of the input source as a `HighlightedText` object. + +## NOTES + +The cmdlet uses the PowerShell grammar shipped with the module. For very large scripts consider chunking input to avoid high memory usage. + +## RELATED LINKS + +See `Format-CSharp` and `Format-Markdown` for other language renderers. diff --git a/docs/en-us/Get-SupportedTextMate.md b/docs/en-us/Get-SupportedTextMate.md new file mode 100644 index 0000000..1e00417 --- /dev/null +++ b/docs/en-us/Get-SupportedTextMate.md @@ -0,0 +1,84 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Get-SupportedTextMate +--- + +# Get-SupportedTextMate + +## SYNOPSIS + +Retrieves a list of supported TextMate languages and grammar metadata available to the module. + +## SYNTAX + +### (All) + +``` +Get-SupportedTextMate [] +``` + +## ALIASES + +This cmdlet has the following aliases, + None + +## DESCRIPTION + +Get-SupportedTextMate returns detailed `TextMateSharp.Grammars.Language` objects describing available grammars, file extensions, scopes, and other metadata. Useful for tooling that needs to map file types to TextMate language IDs. + +## EXAMPLES + +### Example 1 + +Example: list all supported languages + +``` +Get-SupportedTextMate +``` + +### Example 2 + +Example: show language names and extensions + +``` +Get-SupportedTextMate | Select-Object Name, Extensions +``` + +### Example 3 + +Example: find languages supporting .cs files + +``` +Get-SupportedTextMate | Where-Object { $_.Extensions -contains '.cs' } +``` + +## PARAMETERS + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### TextMateSharp.Grammars.Language + +Emits `Language` objects from TextMateSharp describing the grammar, scope name, file extensions and any available configuration. + +## NOTES + +The returned objects can be used by `Show-TextMate` or other consumers to determine which grammar token to apply for a given file. + +## RELATED LINKS + +See `Show-TextMate` for rendering and `Test-SupportedTextMate` for support checks. diff --git a/docs/en-us/Show-TextMate.md b/docs/en-us/Show-TextMate.md new file mode 100644 index 0000000..82992a9 --- /dev/null +++ b/docs/en-us/Show-TextMate.md @@ -0,0 +1,201 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Show-TextMate +--- + +# Show-TextMate + +## SYNOPSIS + +Displays syntax-highlighted text using TextMate grammars. Accepts strings or file input and returns a `HighlightedText` object for rendering. + +## SYNTAX + +### Default (Default) + +``` +Show-TextMate [-InputObject] [-Language ] [-Alternate] [-Theme ] + [-LineNumbers] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + stm, Show-Code + +## DESCRIPTION + +Show-TextMate renders textual input using an appropriate TextMate grammar. +When `-Language` is provided it forces that language; +when omitted the cmdlet may infer language from file extension or default to `powershell`. +Use `-Alternate` to force the standard renderer for Markdown files. + +## EXAMPLES + +### Example 1 + +Example: highlight a snippet with an explicit language + +``` +"print('hello')" | Show-TextMate -Language python +``` + +### Example 2 + +Example: render a file and let the cmdlet infer language from extension + +``` +Show-TextMate -InputObject (Get-Content scripts\deploy.ps1 -Raw) +``` + +### Example 3 + +Example: preview a Markdown file + +``` +Get-Content README.md -Raw | Show-TextMate -Theme SolarizedDark +``` + +## PARAMETERS + +### -Alternate + +Forces the standard (non-markdown-specialized) renderer. Useful for previewing how code blocks will appear under the generic renderer. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -InputObject + +Accepts a `string` or object containing textual content. +Use `Get-Content -Raw` to pass file contents. + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Language + +Hint to force a particular TextMate language ID (for example `powershell`, `csharp`, `python`). +When provided it overrides extension-based inference. + +```yaml +Type: String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +Include line numbers in the output when specified. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Select a `TextMateSharp.Grammars.ThemeName` used for styling output. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts textual input or objects containing text; commonly used with `Get-Content -Raw` or pipeline strings. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns a `HighlightedText` object representing the rendered tokens and styling metadata. + +## NOTES + +If language cannot be inferred and `-Language` is not provided, the cmdlet defaults to `powershell` for string input. Use `-Language` to override detection. + +## RELATED LINKS + +See `Get-SupportedTextMate` to discover available language IDs and `Format-*` cmdlets for language-specific formatting. diff --git a/docs/en-us/Test-SupportedTextMate.md b/docs/en-us/Test-SupportedTextMate.md new file mode 100644 index 0000000..683349e --- /dev/null +++ b/docs/en-us/Test-SupportedTextMate.md @@ -0,0 +1,164 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Test-SupportedTextMate +--- + +# Test-SupportedTextMate + +## SYNOPSIS + +Tests whether a language, extension, or file is supported by the module's TextMate grammars. Returns a boolean or diagnostic object indicating support. + +## SYNTAX + +### FileSet (Default) + +``` +Test-SupportedTextMate -File [] +``` + +### ExtensionSet + +``` +Test-SupportedTextMate -Extension [] +``` + +### LanguageSet + +``` +Test-SupportedTextMate -Language [] +``` + +## ALIASES + +This cmdlet has the following aliases, + None + +## DESCRIPTION + +Test-SupportedTextMate verifies support for TextMate languages and extensions. +Use the `-File` parameter to check a specific file path, `-Extension` to verify a file extension, or `-Language` to test a language identifier. +The cmdlet returns `true` or `false` + +## EXAMPLES + +### Example 1 + +Example: test a file path for support + +``` +Test-SupportedTextMate -File .\src\Program.cs +``` + +### Example 2 + +Example: test by extension + +``` +Test-SupportedTextMate -Extension .ps1 +``` + +### Example 3 + +Example: test by language identifier + +``` +Test-SupportedTextMate -Language powershell +``` + +## PARAMETERS + +### -Extension + +File extension to test (for example `.ps1`, `.cs`). +When used the cmdlet returns whether the module has a grammar associated with that extension. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: ExtensionSet + Position: Named + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -File + +Path to a file to test for grammar support. +The path is resolved and existence is validated before checking support. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: FileSet + Position: Named + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Language + +TextMate language ID to test (for example `powershell`, `csharp`, `markdown`). +Returns whether that language ID is supported. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: LanguageSet + Position: Named + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### bool + +Returns `bool` results for support checks. In error cases or file-not-found scenarios the cmdlet may write errors or diagnostic objects to the pipeline. + +## NOTES + +Use `Get-SupportedTextMate` to discover available language IDs and their extensions before calling this cmdlet. + +## RELATED LINKS + +See `Get-SupportedTextMate` and `Show-TextMate` for discovery and rendering workflows. diff --git a/src/Cmdlets/MeasureStringCmdlet.cs b/src/Cmdlets/MeasureStringCmdlet.cs deleted file mode 100644 index 64f06f8..0000000 --- a/src/Cmdlets/MeasureStringCmdlet.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Management.Automation; -using System.Runtime.InteropServices; -using PSTextMate.Helpers; - -namespace PSTextMate.Commands; - -/// -/// Cmdlet for measuring grapheme width and cursor movement for a string. -/// -[Cmdlet(VerbsDiagnostic.Measure, "String", DefaultParameterSetName = "Default")] -[OutputType(typeof(GraphemeMeasurement), typeof(bool), typeof(int))] -public sealed class MeasureStringCmdlet : PSCmdlet { - /// - /// The input string to measure. - /// - [Parameter( - Mandatory = true, - Position = 0, - ValueFromPipeline = true, - ValueFromPipelineByPropertyName = true - )] - [AllowEmptyString] - public string? InputString { get; set; } - - [Parameter] - public SwitchParameter IgnoreVT { get; set; } - - [Parameter( - ParameterSetName = "Wide" - )] - public SwitchParameter IsWide { get; set; } - - [Parameter( - ParameterSetName = "Visible" - )] - public SwitchParameter VisibleLength { get; set; } - protected override void ProcessRecord() { - if (InputString is null) { - return; - } - GraphemeMeasurement measurement = Grapheme.Measure(InputString, !IgnoreVT.IsPresent); - switch (ParameterSetName) { - case "Wide": { - WriteObject(measurement.HasWideCharacters); - break; - } - case "Visible": { - WriteObject(measurement.Cells); - break; - } - default: { - WriteObject(measurement); - break; - } - } - } -} diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index 91d2b81..f1c32de 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -11,7 +11,7 @@ namespace PSTextMate.Commands; /// Supports both string input and file processing with theme customization. /// [Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "Default")] -[Alias("st", "Show-Code")] +[Alias("stm", "Show-Code")] [OutputType(typeof(HighlightedText))] public sealed class ShowTextMateCmdlet : TextMateCmdletBase { /// diff --git a/src/Cmdlets/TestSupportedTextMate.cs b/src/Cmdlets/TestSupportedTextMate.cs index 127faa6..042dbbb 100644 --- a/src/Cmdlets/TestSupportedTextMate.cs +++ b/src/Cmdlets/TestSupportedTextMate.cs @@ -8,7 +8,8 @@ namespace PSTextMate.Commands; /// Cmdlet for testing TextMate support for languages, extensions, and files. /// Provides validation functionality to check compatibility before processing. /// -[Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate", DefaultParameterSetName = "File")] +[OutputType(typeof(bool))] +[Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate", DefaultParameterSetName = "FileSet")] public sealed class TestSupportedTextMateCmdlet : PSCmdlet { /// /// File extension to test for support (e.g., '.ps1'). diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index c4da0cf..fa3cc59 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -6,7 +6,7 @@ 0.1.0 enable 13.0 - true + @@ -21,7 +21,7 @@ - + @@ -37,17 +37,4 @@ - - - diff --git a/src/Utilities/Grapheme.cs b/src/Utilities/Grapheme.cs deleted file mode 100644 index 1f86853..0000000 --- a/src/Utilities/Grapheme.cs +++ /dev/null @@ -1,952 +0,0 @@ -using System.Runtime.CompilerServices; -using PSTextMate.Helpers; - -namespace PSTextMate.Helpers; - -// original from: https://gist.github.com/lhecker/cf12813c65684b6ff74f10dbf3672686 -// https://github.com/microsoft/terminal/blob/main/src/tools/GraphemeTableGen/Program.cs -// seems based on https://github.com/microsoft/terminal/blob/main/src/types/CodepointWidthDetector.cpp -// https://github.com/microsoft/terminal/blob/main/src/types/GlyphWidth.cpp - -/// -/// grapheme measurement -/// -/// Input string. -/// UTF-16 length of the input string. -/// Number of grapheme cursor movements. -/// Total column width of graphemes. -/// UTF-16 offset where measurement stopped. -/// String contains VT/OSC/CSI. -public readonly struct GraphemeMeasurement { - public string Text { get; } - public int StringLength => Text.Length; - /// CursorMovements - public int Cells { get; } - /// Columns - public int Length { get; } - public int EndOffset { get; } - public bool HasWideCharacters { get; } - public bool? ContainsVT { get; } - public GraphemeMeasurement(string input, int cells, int length, int endOffset, bool? containsVT) { - Text = input; - Cells = cells; - Length = length; - EndOffset = endOffset; - ContainsVT = containsVT; - HasWideCharacters = length > cells; - } - public override string ToString() => $"StringLength: {StringLength}, Cells: {Cells}, Length: {Length}, Wide: {HasWideCharacters}, VT: {ContainsVT}"; -} -public static class Grapheme { - /// - /// Measure a string and return combined grapheme results. - /// - /// - /// When true, VT escape sequences are ignored. - /// - /// - /// Combined grapheme measurement. - public static GraphemeMeasurement Measure(string input, bool useVT = true, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { - ArgumentNullException.ThrowIfNull(input); - - if (useVT) { - MeasurementResult forward = MeasureForwardVT(input, 0, maxCursorMovements, maxColumns, out bool containsVT); - return new GraphemeMeasurement(input, forward.CursorMovements, forward.Columns, forward.Offset, containsVT); - } - - MeasurementResult nonVT = MeasureForward(input, 0, maxCursorMovements, maxColumns); - return new GraphemeMeasurement(input, nonVT.CursorMovements, nonVT.Columns, nonVT.Offset, null); - } - - /// - /// Returns true when the input contains any wide grapheme (column width 2). - /// - /// - /// When true, VT escape sequences are ignored. - /// True if a wide grapheme is present. - public static bool ContainsWide(string input, bool useVT = true) => - Measure(input, useVT).HasWideCharacters; - - /// - /// Returns the total visible column width for the input string. - /// - /// - /// When true, VT escape sequences are ignored. - /// Visible column width. - public static int VisibleLength(string input, bool useVT = true) => - Measure(input, useVT).Cells; - - /// - /// Unicode tables - /// - private static readonly byte[] s_stage0 = [ - 0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x33, 0x33, 0x36, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, - 0x33, 0x33, 0x33, 0x33, 0x3c, 0x44, 0x4c, 0x4d, 0x4e, 0x48, 0x50, 0x58, 0x58, 0x58, 0x58, 0x5f, - 0x67, 0x6d, 0x75, 0x7d, 0x58, 0x58, 0x85, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x8b, 0x33, 0x33, - 0x93, 0x9b, 0x58, 0x58, 0x58, 0xa1, 0xa9, 0xad, 0x58, 0xb2, 0xba, 0xc0, 0xc8, 0xd0, 0xd8, 0xe0, - 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, - 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0xe8, - 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, - 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0xe8, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0xf0, 0xf2, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, 0x58, - ]; - private static readonly ushort[] s_stage1 = [ - 0x0000, 0x000b, 0x000b, 0x001b, 0x0023, 0x002c, 0x003c, 0x004c, - 0x005c, 0x006c, 0x007c, 0x008c, 0x009c, 0x00ac, 0x00bc, 0x00cb, - 0x00d9, 0x00e9, 0x000b, 0x00f9, 0x000b, 0x000b, 0x000b, 0x0108, - 0x0118, 0x0126, 0x0135, 0x0145, 0x0155, 0x000f, 0x000b, 0x000b, - 0x0165, 0x0175, 0x000b, 0x0184, 0x0194, 0x01a1, 0x01b1, 0x01c1, - 0x000b, 0x01ce, 0x000b, 0x01de, 0x01e4, 0x01f4, 0x0204, 0x0214, - 0x0223, 0x0233, 0x0242, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, - 0x024c, 0x024c, 0x024c, 0x0250, 0x024c, 0x024c, 0x024c, 0x024c, - 0x0260, 0x000b, 0x026d, 0x000b, 0x027d, 0x028d, 0x029c, 0x02ac, - 0x02bc, 0x02be, 0x02c0, 0x02c2, 0x02bd, 0x02bf, 0x02c1, 0x02bc, - 0x02be, 0x02c0, 0x02c2, 0x02bd, 0x02bf, 0x02c1, 0x02bc, 0x02cc, - 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, - 0x024c, 0x024c, 0x02dc, 0x000b, 0x000b, 0x02ec, 0x02fc, 0x000b, - 0x030c, 0x031c, 0x032b, 0x000b, 0x000b, 0x000b, 0x000b, 0x033b, - 0x000b, 0x000b, 0x034a, 0x0350, 0x0360, 0x0370, 0x0380, 0x038e, - 0x039e, 0x03ab, 0x03b8, 0x03c6, 0x03d5, 0x03e3, 0x03f0, 0x0400, - 0x000b, 0x040e, 0x041b, 0x0425, 0x0435, 0x000b, 0x000b, 0x000b, - 0x000b, 0x0442, 0x000b, 0x000b, 0x000b, 0x0448, 0x0458, 0x000b, - 0x000b, 0x000b, 0x0464, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, - 0x024c, 0x024c, 0x0474, 0x024c, 0x024c, 0x024c, 0x024c, 0x0484, - 0x0494, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, - 0x0495, 0x024c, 0x04a5, 0x04ac, 0x000b, 0x000b, 0x000b, 0x000b, - 0x000b, 0x04bc, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, - 0x000b, 0x04cc, 0x000b, 0x04d6, 0x04e2, 0x000b, 0x000b, 0x000b, - 0x000b, 0x000b, 0x04f2, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, - 0x0502, 0x0458, 0x050b, 0x000b, 0x051a, 0x000b, 0x000b, 0x000b, - 0x0529, 0x0537, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, 0x000b, - 0x0547, 0x0557, 0x0567, 0x0577, 0x0587, 0x0597, 0x05a7, 0x05b7, - 0x05c7, 0x05d7, 0x05e7, 0x000b, 0x05f7, 0x05f7, 0x05f7, 0x05f8, - 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, 0x024c, 0x0608, - 0x0618, 0x0628, 0x0637, 0x0637, 0x0637, 0x0637, 0x0637, 0x0637, - 0x0637, 0x0637, - ]; - private static readonly ushort[] s_stage2 = [ - 0x0000, 0x0000, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0011, 0x0000, 0x0000, 0x0021, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, - 0x0031, 0x0031, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0041, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0030, 0x0031, 0x0051, 0x005f, 0x0010, 0x0010, 0x0010, 0x006f, 0x007f, 0x0010, 0x0010, - 0x008c, 0x0031, 0x0010, 0x009b, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x00a5, 0x00b4, 0x0010, 0x00c2, 0x00d2, 0x0010, 0x0031, - 0x00e2, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x004b, 0x009b, 0x0010, 0x0010, 0x008c, 0x00e9, 0x0010, 0x00f7, 0x0103, 0x0010, - 0x0010, 0x0111, 0x0010, 0x0010, 0x0010, 0x0121, 0x0010, 0x0010, 0x0075, 0x0031, 0x012f, 0x0031, 0x0098, 0x013f, 0x0144, 0x014a, - 0x0158, 0x0168, 0x0178, 0x0180, 0x0190, 0x013f, 0x01a0, 0x01af, 0x01bd, 0x01cb, 0x0178, 0x01da, 0x0190, 0x0010, 0x0010, 0x01dc, - 0x01ea, 0x00d2, 0x0010, 0x01f6, 0x0190, 0x013f, 0x01a0, 0x0206, 0x0214, 0x0010, 0x0178, 0x0222, 0x0190, 0x013f, 0x01a0, 0x0206, - 0x01bd, 0x0232, 0x0178, 0x0240, 0x024e, 0x0010, 0x0010, 0x009d, 0x025e, 0x0249, 0x0010, 0x0010, 0x0097, 0x013f, 0x01a0, 0x026e, - 0x027c, 0x028a, 0x0178, 0x0010, 0x0190, 0x0010, 0x0010, 0x01dc, 0x029a, 0x02a8, 0x0178, 0x024d, 0x0098, 0x013f, 0x0144, 0x02b8, - 0x02c6, 0x0249, 0x0178, 0x0010, 0x0190, 0x0010, 0x0010, 0x0010, 0x02d5, 0x02e4, 0x0010, 0x0178, 0x0010, 0x0010, 0x0010, 0x02f4, - 0x02ff, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x030e, 0x031b, 0x0010, 0x0010, 0x0010, 0x032a, 0x0010, 0x0335, 0x0010, - 0x0010, 0x0010, 0x0030, 0x0343, 0x0350, 0x0031, 0x0034, 0x024a, 0x0010, 0x0010, 0x0010, 0x0360, 0x036d, 0x0010, 0x037c, 0x038b, - 0x00bd, 0x0399, 0x03a9, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x03b9, 0x03b9, 0x03b9, 0x03b9, 0x03b9, 0x03b9, 0x03c9, - 0x03c9, 0x03c9, 0x03c9, 0x03d1, 0x03d9, 0x03d9, 0x03d9, 0x03d9, 0x03d9, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009e, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x03e9, 0x0010, 0x03f7, 0x0010, 0x0178, 0x0010, 0x0178, - 0x0010, 0x0010, 0x0010, 0x004d, 0x0031, 0x00e9, 0x0010, 0x0010, 0x03fc, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x02a8, 0x0010, 0x00ed, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0133, 0x0133, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0090, 0x0010, 0x0010, 0x0010, 0x040c, 0x041c, 0x0421, 0x0010, 0x0010, 0x0010, - 0x0031, 0x0032, 0x0010, 0x0010, 0x0010, 0x0097, 0x0010, 0x0010, 0x004d, 0x0097, 0x0010, 0x008c, 0x0098, 0x0099, 0x0010, 0x042f, - 0x0010, 0x0010, 0x0010, 0x004b, 0x0098, 0x0010, 0x0010, 0x004d, 0x00e5, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0154, 0x0434, 0x043d, 0x0447, 0x0010, 0x0457, 0x0466, 0x0469, 0x0010, 0x0479, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0031, 0x0031, 0x009b, 0x0010, 0x0010, 0x0489, 0x0469, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0495, 0x049f, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x04aa, 0x04b6, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x04c1, 0x0010, 0x0010, 0x0010, - 0x04ca, 0x0010, 0x04da, 0x04ea, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0489, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x04f5, 0x04c3, 0x04c9, 0x0010, 0x0010, - 0x0501, 0x0511, 0x051e, 0x0524, 0x0524, 0x052c, 0x0538, 0x0524, 0x0525, 0x0542, 0x0552, 0x0561, 0x056d, 0x0576, 0x0580, 0x0558, - 0x058e, 0x059c, 0x05a9, 0x05b5, 0x04fc, 0x05c1, 0x05d0, 0x05dd, 0x0010, 0x0010, 0x05e8, 0x04c8, 0x05ef, 0x0010, 0x0010, 0x0010, - 0x0010, 0x04a4, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x05ff, 0x0607, - 0x0010, 0x0010, 0x0010, 0x0613, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x009c, 0x009a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0031, 0x0031, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0623, 0x0629, 0x0623, 0x0623, - 0x0623, 0x0623, 0x0623, 0x0635, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0645, 0x0010, 0x0623, 0x0623, 0x0655, 0x0665, 0x0622, 0x0623, 0x0623, 0x0623, 0x0623, 0x0675, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0623, 0x0623, 0x061e, 0x0623, 0x0623, 0x0622, 0x0623, 0x0623, 0x0623, 0x0623, 0x0624, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0681, 0x0623, 0x0624, 0x0623, 0x0623, 0x0690, 0x0623, 0x0623, 0x0623, 0x0623, 0x06a0, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x06aa, 0x0623, 0x0623, 0x0623, 0x06b0, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x009c, 0x06c0, 0x0010, 0x009d, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009a, 0x06ce, 0x0010, 0x06db, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009a, 0x0010, 0x0010, 0x004d, 0x035a, 0x0010, 0x0031, 0x06eb, 0x0010, 0x0010, 0x06f4, - 0x0010, 0x0078, 0x0098, 0x03b9, 0x0704, 0x0098, 0x0010, 0x0010, 0x004e, 0x009b, 0x0010, 0x024b, 0x0010, 0x0010, 0x0076, 0x00e6, - 0x0711, 0x0010, 0x0010, 0x071f, 0x0010, 0x0010, 0x0010, 0x072f, 0x00d2, 0x0010, 0x008c, 0x02a8, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x073a, 0x0010, 0x074a, 0x074e, 0x075b, 0x0752, - 0x075b, 0x0756, 0x075b, 0x074a, 0x074e, 0x075b, 0x0752, 0x075b, 0x0756, 0x075b, 0x074a, 0x074e, 0x075b, 0x0752, 0x075b, 0x0756, - 0x075b, 0x074a, 0x074e, 0x075b, 0x0752, 0x075b, 0x0767, 0x03c9, 0x0777, 0x03d9, 0x03d9, 0x0782, 0x0010, 0x0242, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0792, 0x06ad, 0x0031, 0x0623, - 0x0623, 0x07a2, 0x07ab, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x07b7, 0x0622, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0623, 0x07b6, 0x0010, 0x0010, 0x07c7, 0x0010, 0x0010, 0x0010, 0x0010, 0x06b0, 0x07d7, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0243, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009b, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0091, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x07e6, 0x0010, 0x0010, 0x07f6, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x02a8, 0x0010, 0x0010, 0x07ee, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0806, 0x0010, 0x0010, 0x0010, 0x0010, 0x009e, - 0x0010, 0x0010, 0x0010, 0x0010, 0x004b, 0x009b, 0x0010, 0x0010, 0x03e9, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0099, 0x0010, 0x0010, 0x0077, 0x00e6, 0x0010, 0x0010, 0x0816, 0x0099, 0x0010, 0x0010, 0x0825, 0x0833, 0x0010, 0x0010, 0x0010, - 0x0099, 0x0010, 0x0078, 0x0097, 0x02a8, 0x0010, 0x0010, 0x024d, 0x0099, 0x0010, 0x0010, 0x004e, 0x0843, 0x0010, 0x0010, 0x0010, - 0x009f, 0x0851, 0x00d2, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x00e2, 0x0010, 0x0098, 0x0010, - 0x0010, 0x0860, 0x086e, 0x0249, 0x087c, 0x0097, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x004c, 0x00e6, - 0x0242, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0031, 0x0098, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x009c, 0x0428, 0x009b, 0x0889, 0x0010, 0x0010, 0x0010, 0x0031, 0x009b, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x008c, 0x00e5, 0x0010, 0x0010, 0x0010, 0x0010, 0x009e, 0x0899, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009f, 0x00e2, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x08a9, 0x08b7, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x08c6, 0x08d5, 0x0010, - 0x08e4, 0x0010, 0x0010, 0x08f1, 0x0249, 0x00e1, 0x0010, 0x0010, 0x0900, 0x00e3, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x009c, 0x090a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x004f, 0x0350, 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, 0x091a, 0x0929, - 0x0010, 0x0010, 0x0010, 0x008d, 0x0109, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0936, 0x0946, 0x0010, 0x0010, 0x0952, 0x0099, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0962, 0x004a, 0x035a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0097, 0x0010, 0x0010, 0x0010, 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x0030, 0x0031, 0x0031, 0x0972, 0x0099, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0982, 0x009a, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0623, 0x0623, 0x0690, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0645, 0x0010, 0x0010, 0x06ae, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0992, 0x0623, 0x0623, 0x07b4, 0x09a1, 0x0010, 0x09b1, 0x09bd, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x06ab, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x09c5, 0x0462, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0031, 0x0031, 0x0033, 0x0031, - 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x09d4, 0x09e1, 0x09ee, 0x0010, - 0x09fa, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x03f7, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0031, 0x0031, 0x0031, 0x089e, 0x0031, 0x0031, 0x0034, 0x024b, 0x024c, 0x008c, 0x0030, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x090a, 0x0425, 0x0a0a, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009c, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0242, 0x0010, 0x0010, 0x0010, 0x009f, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x009f, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x00e6, 0x0010, 0x0010, 0x0010, 0x0010, 0x031f, 0x0010, 0x0010, 0x0010, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0580, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, - 0x0524, 0x0524, 0x0524, 0x0525, 0x0524, 0x0524, 0x0524, 0x048c, 0x0010, 0x04ca, 0x0010, 0x0010, 0x0010, 0x048d, 0x0a1a, 0x05f0, - 0x0a2a, 0x048c, 0x0524, 0x0524, 0x0524, 0x0a3a, 0x0a40, 0x0a50, 0x0a60, 0x0a6b, 0x0a78, 0x0a88, 0x0522, 0x0536, 0x0524, 0x0524, - 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0a98, 0x0a98, 0x0aa7, 0x0ab4, 0x0a98, 0x0a98, 0x0a98, 0x0abb, 0x0a98, - 0x0538, 0x0a98, 0x0a98, 0x0ac9, 0x0538, 0x0a98, 0x0ad8, 0x0a98, 0x0a98, 0x0a98, 0x0a99, 0x0ae8, 0x0a98, 0x0a98, 0x0a98, 0x0a98, - 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0aeb, 0x0a98, 0x0a98, 0x0a98, 0x0afa, 0x0b08, 0x0a98, 0x0534, 0x0558, 0x0524, - 0x0566, 0x0580, 0x0524, 0x0524, 0x0524, 0x0524, 0x0529, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0010, 0x0010, 0x0010, 0x0a98, - 0x0a98, 0x0a98, 0x0a98, 0x0b18, 0x0b28, 0x056f, 0x0b30, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0010, 0x0b40, 0x0010, - 0x0010, 0x0010, 0x0010, 0x0010, 0x0b50, 0x0a9c, 0x0523, 0x048d, 0x0010, 0x0010, 0x0010, 0x0b60, 0x048f, 0x0010, 0x0010, 0x0b60, - 0x0010, 0x0b70, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0b80, 0x0a98, 0x0a98, 0x0b8c, 0x0b91, 0x0a98, 0x0a98, 0x0a98, 0x0a98, - 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0a98, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0a9b, 0x0a9f, - 0x0a98, 0x0a98, 0x0b98, 0x0ba7, 0x0a9c, 0x0a9f, 0x0a9f, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, - 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0524, 0x0bb7, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, - 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0623, 0x0bc7, 0x0bd7, 0x0000, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, - 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0031, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - ]; - private static readonly byte[] s_stage3 = [ - 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x41, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x01, 0x4c, - 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x04, - 0x04, 0x04, 0x04, 0x04, 0x04, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x01, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x04, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, - 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x04, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x04, 0x04, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x04, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, - 0x4b, 0x4b, 0x4b, 0x4b, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x0a, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, - 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, - 0x40, 0x4b, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x0a, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x40, 0x4b, 0x4b, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x40, 0x4b, 0x4b, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, - 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, - 0x02, 0x0a, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, - 0x40, 0x4b, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, - 0x02, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x4b, 0x4b, - 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x0a, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, - 0x02, 0x40, 0x4b, 0x4b, 0x4b, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, 0x4b, - 0x4b, 0x4b, 0x4b, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x40, - 0x02, 0x02, 0x02, 0x0a, 0x44, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, - 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x40, 0x42, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, - 0x40, 0x42, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x00, 0x00, 0x00, - 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x02, 0x00, 0x02, 0x02, 0x02, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x40, 0x00, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x00, 0x00, 0x00, 0x02, 0x40, 0x40, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, - 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, - 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, - 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x01, 0x02, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x00, 0x02, 0x00, - 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x01, 0x02, 0x0d, 0x01, 0x01, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x41, - 0x41, 0x01, 0x01, 0x01, 0x01, 0x01, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x01, 0x01, 0x01, 0x01, 0x01, 0x41, 0x01, - 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x80, - 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x8c, 0x40, 0x40, - 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, - 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x8c, 0x8c, - 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x40, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x8c, 0x8c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x8c, 0x40, 0x40, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x4c, 0x40, 0x4c, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, 0x8c, - 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, 0x40, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x8c, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x80, 0x8c, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x02, 0x02, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x8c, 0x80, 0x8c, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, - 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x40, 0x40, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, 0x85, - 0x85, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x02, 0x00, 0x40, 0x40, 0x02, - 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x40, 0x88, 0x89, 0x89, 0x89, 0x89, 0x89, - 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x88, 0x89, 0x89, 0x89, 0x89, 0x89, - 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x89, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x40, 0x40, - 0x40, 0x40, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x47, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x82, 0x80, 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x01, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x42, 0x42, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - 0x01, 0x01, 0x01, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, - 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x40, 0x40, 0x04, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x04, 0x40, 0x40, 0x02, 0x40, 0x44, 0x44, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, - 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, - 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x00, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x44, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x44, 0x02, 0x02, 0x02, 0x02, - 0x40, 0x40, 0x40, 0x40, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x40, 0x40, 0x02, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x44, - 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x44, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, - 0x02, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, - 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x02, 0x80, 0x80, 0x80, 0x80, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x80, - 0x80, 0x40, 0x40, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x80, 0x80, 0x80, 0x40, 0x40, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, - 0x40, 0x02, 0x02, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, - 0x02, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x02, 0x02, 0x40, 0x02, 0x02, 0x40, - 0x02, 0x02, 0x02, 0x02, 0x02, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x40, 0x40, 0x40, 0x40, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, - 0x80, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x8c, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x80, 0x4c, 0x4c, 0x4c, 0x4c, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, - 0x8c, 0x8c, 0x8c, 0x82, 0x82, 0x82, 0x82, 0x82, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, - 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x4c, 0x4c, - 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x40, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, - 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x8c, 0x8c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, - 0x4c, 0x4c, 0x4c, 0x4c, 0x4c, 0x40, 0x40, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, - 0x80, 0x80, 0x80, 0x80, 0x80, 0x40, 0x40, 0x41, 0x01, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - ]; - private static readonly uint[][] s_joinRules = [ - [ - 0b00000011110011111111111111001111, - 0b00001111111111111111111111111111, - 0b00000011110011111111111111001111, - 0b00000011110011111111111101001111, - 0b00000000000000000000000000001100, - 0b00000011110000001100001111001111, - 0b00000011110011110000111111001111, - 0b00000011110011110011111111001111, - 0b00000011110011110000111111001111, - 0b00000011110011110011111111001111, - 0b00000011000011111111111111001111, - 0b00000011110011111111111111001111, - 0b00000011110011111111111111001111, - 0b00000000110011111111111111001111, - 0b00000000000000000000000000000000, - 0b00000000000000000000000000000000, - ], - [ - 0b00000011110011111111111111001111, - 0b00001111111111111111111111111111, - 0b00000011110011111111111111001111, - 0b00000011110011111111111111001111, - 0b00000000000000000000000000001100, - 0b00000011110000001100001111001111, - 0b00000011110011110000111111001111, - 0b00000011110011110011111111001111, - 0b00000011110011110000111111001111, - 0b00000011110011110011111111001111, - 0b00000011000011111111111111001111, - 0b00000011110011111111111111001111, - 0b00000011110011111111111111001111, - 0b00000000110011111111111111001111, - 0b00000000000000000000000000000000, - 0b00000000000000000000000000000000, - ], - ]; - /// - /// helper methods - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int UcdLookup(uint cp) { - byte s0 = s_stage0[cp >> 11]; - ushort s1 = s_stage1[s0 + ((cp >> 8) & 7)]; - ushort s2 = s_stage2[s1 + ((cp >> 4) & 15)]; - return s_stage3[s2 + ((cp >> 0) & 15)]; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static uint UcdGraphemeJoins(uint state, int lead, int trail) { - int l = lead & 15; - int t = trail & 15; - return (s_joinRules[state][l] >> (t * 2)) & 3; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool UcdGraphemeDone(uint state) => state == 3; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int UcdToCharacterWidth(int val) => val >> 6; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int Utf16NextOrFFFD(string str, int offset, out uint cp) { - uint c = str[offset]; - offset++; - - // Is any surrogate? - if ((c & 0xF800) == 0xD800) { - uint c1 = c; - c = 0xfffd; - - // Is leading surrogate and not at end? - if ((c1 & 0x400) == 0 && offset < str.Length) { - char c2 = str[offset]; - // Is also trailing surrogate! - if ((c2 & 0xFC00) == 0xDC00) { - c = (c1 << 10) - 0x35FDC00 + c2; - offset++; - } - } - } - - cp = c; - return offset; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static int Utf16PrevOrFFFD(string str, int offset, out uint cp) { - offset--; - uint c = str[offset]; - - // Is any surrogate? - if ((c & 0xF800) == 0xD800) { - uint c2 = c; - c = 0xfffd; - - // Is trailing surrogate and not at begin? - if ((c2 & 0x400) != 0 && offset != 0) { - uint c1 = str[offset - 1]; - // Is also leading surrogate! - if ((c1 & 0xFC00) == 0xD800) { - c = (c1 << 10) - 0x35FDC00 + c2; - offset--; - } - } - } - - cp = c; - return offset; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TrySkipVTForward(string str, ref int offset) { - int length = str.Length; - - if (offset >= length) { - return false; - } - - char first = str[offset]; - int index = offset; - - switch (first) { - case '\u001b': - if (index + 1 >= length) { - offset = length; - return true; - } - - if (str[index + 1] == '[') { - index += 2; - SkipCsi(str, length, ref index); - offset = index; - return true; - } - - if (str[index + 1] is ']' or 'P' or '^' or '_') { - index += 2; - SkipOscLike(str, length, ref index); - offset = index; - return true; - } - - offset = Math.Min(length, index + 2); - return true; - - case '\u009b': - index++; - SkipCsi(str, length, ref index); - offset = index; - return true; - - case '\u009d': - case '\u0090': - case '\u009e': - case '\u009f': - index++; - SkipOscLike(str, length, ref index); - offset = index; - return true; - - default: - return false; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static bool TrySkipVTBackward(string str, ref int offset) { - int length = str.Length; - - if (offset <= 0 || offset > length) { - return false; - } - - int scan = offset; - - while (scan > 0) { - int start = scan - 1; - char c = str[start]; - - if (c is '\u001b' or (>= '\u0090' and <= '\u009f')) { - int forward = start; - if (TrySkipVTForward(str, ref forward) && forward >= offset) { - offset = start; - return true; - } - } - - scan = start; - } - - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SkipCsi(string str, int length, ref int index) { - while (index < length) { - if (str[index] is >= (char)0x40 and <= (char)0x7e) { - index++; - return; - } - - if (str[index] is < (char)0x20 or > (char)0x3f) { - index++; - return; - } - - index++; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SkipOscLike(string str, int length, ref int index) { - while (index < length) { - if (str[index] == '\u0007') { - index++; - return; - } - - if (str[index] == '\u001b' && index + 1 < length && str[index + 1] == '\\') { - index += 2; - return; - } - index++; - } - } - - /// - /// MeasureForward - /// - /// - /// - /// - /// - /// - public static MeasurementResult MeasureForward(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { - int cursorMovements = 0; - int columns = 0; - - if (offset < str.Length && maxCursorMovements > 0 && maxColumns > 0) { - int offsetTrail = Utf16NextOrFFFD(str, offset, out uint cp); - int lead = UcdLookup(cp); - - while (true) { - int offsetLead = offsetTrail; - int width = 0; - uint state = 0; - - while (true) { - width += UcdToCharacterWidth(lead); - - if (offsetTrail >= str.Length) { - break; - } - - offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); - int trail = UcdLookup(cp); - state = UcdGraphemeJoins(state, lead, trail); - lead = trail; - - if (UcdGraphemeDone(state)) { - break; - } - - offsetLead = offsetTrail; - } - - width = Math.Min(2, width); - - if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { - break; - } - - offset = offsetLead; - cursorMovements += 1; - columns += width; - - if (offset >= str.Length) { - break; - } - } - } - - return new MeasurementResult(offset, cursorMovements, columns); - } - - /// - /// MeasureForward - /// - /// - /// - /// - /// - /// - public static MeasurementResult MeasureForwardVT(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) => - MeasureForwardVT(str, offset, maxCursorMovements, maxColumns, out _); - - private static MeasurementResult MeasureForwardVT(string str, int offset, int maxCursorMovements, int maxColumns, out bool containsVT) { - int cursorMovements = 0; - int columns = 0; - bool sawVT = false; - - if (offset < str.Length && maxCursorMovements > 0 && maxColumns > 0) { - while (offset < str.Length && TrySkipVTForward(str, ref offset)) { - sawVT = true; - } - - if (offset >= str.Length) { - containsVT = sawVT; - return new MeasurementResult(offset, cursorMovements, columns); - } - - int offsetTrail = Utf16NextOrFFFD(str, offset, out uint cp); - int lead = UcdLookup(cp); - - do { - // Skip VT sequences before reading the next codepoint to keep state aligned. - while (offsetTrail < str.Length && TrySkipVTForward(str, ref offsetTrail)) { - sawVT = true; - } - - int offsetLead = offsetTrail; - int width = 0; - uint state = 0; - - while (true) { - width += UcdToCharacterWidth(lead); - - if (offsetTrail >= str.Length) { - break; - } - - while (offsetTrail < str.Length && TrySkipVTForward(str, ref offsetTrail)) { - sawVT = true; - } - - if (offsetTrail >= str.Length) { - break; - } - - offsetTrail = Utf16NextOrFFFD(str, offsetTrail, out cp); - - int trail = UcdLookup(cp); - state = UcdGraphemeJoins(state, lead, trail); - lead = trail; - - if (UcdGraphemeDone(state)) { - break; - } - - offsetLead = offsetTrail; - } - - width = Math.Min(2, width); - - if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { - break; - } - - offset = offsetLead; - cursorMovements++; - columns += width; - } - while (offset < str.Length); - } - - containsVT = sawVT; - return new MeasurementResult(offset, cursorMovements, columns); - } - - /// - /// MeasureBackward - /// - /// - /// - /// - /// - /// - public static MeasurementResult MeasureBackward(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { - int cursorMovements = 0; - int columns = 0; - - if (offset > 0 && maxCursorMovements > 0 && maxColumns > 0) { - int offsetLead = Utf16PrevOrFFFD(str, offset, out uint cp); - int trail = UcdLookup(cp); - - while (true) { - int offsetTrail = offsetLead; - int width = 0; - uint state = 0; - - while (true) { - width += UcdToCharacterWidth(trail); - - if (offsetLead <= 0) { - break; - } - - offsetLead = Utf16PrevOrFFFD(str, offsetLead, out cp); - int lead = UcdLookup(cp); - state = UcdGraphemeJoins(state, lead, trail); - trail = lead; - - if (UcdGraphemeDone(state)) { - break; - } - - offsetTrail = offsetLead; - } - - width = Math.Min(2, width); - - if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { - break; - } - - offset = offsetTrail; - cursorMovements += 1; - columns += width; - - if (offset <= 0) { - break; - } - } - } - - return new MeasurementResult(offset, cursorMovements, columns); - } - - /// - /// MeasureBackward - /// - /// - /// - /// - /// - /// - public static MeasurementResult MeasureBackwardVT(string str, int offset = 0, int maxCursorMovements = int.MaxValue, int maxColumns = int.MaxValue) { - int cursorMovements = 0; - int columns = 0; - - if (offset > 0 && maxCursorMovements > 0 && maxColumns > 0) { - while (offset > 0 && TrySkipVTBackward(str, ref offset)) { - } - - if (offset <= 0) { - return new MeasurementResult(offset, cursorMovements, columns); - } - - int offsetLead = Utf16PrevOrFFFD(str, offset, out uint cp); - int trail = UcdLookup(cp); - - do { - int offsetTrail = offsetLead; - int width = 0; - uint state = 0; - - while (true) { - width += UcdToCharacterWidth(trail); - - if (offsetLead <= 0) { - break; - } - - while (offsetLead > 0 && TrySkipVTBackward(str, ref offsetLead)) { - } - - if (offsetLead <= 0) { - break; - } - - offsetLead = Utf16PrevOrFFFD(str, offsetLead, out cp); - int lead = UcdLookup(cp); - state = UcdGraphemeJoins(state, lead, trail); - trail = lead; - - if (UcdGraphemeDone(state)) { - break; - } - - offsetTrail = offsetLead; - } - - width = Math.Min(2, width); - - if (columns + width > maxColumns || cursorMovements + 1 > maxCursorMovements) { - break; - } - - offset = offsetTrail; - cursorMovements++; - columns += width; - - while (offset > 0 && TrySkipVTBackward(str, ref offset)) { - } - - if (offset > 0) { - offsetLead = Utf16PrevOrFFFD(str, offset, out cp); - trail = UcdLookup(cp); - } - } - while (offset > 0); - } - - return new MeasurementResult(offset, cursorMovements, columns); - } - public readonly record struct MeasurementResult(int Offset, int CursorMovements, int Columns) { } - -} From 81f5d7e68fb58addd520d4731a21e5da63b03b6d Mon Sep 17 00:00:00 2001 From: trackd <17672644+trackd@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:48:44 +0100 Subject: [PATCH 25/25] implement suggestions --- src/Cmdlets/TestSupportedTextMate.cs | 10 ++++++---- src/Rendering/TableRenderer.cs | 23 ++++++++++++----------- src/Utilities/TokenStyleProcessor.cs | 2 +- tests/testhelper.psm1 | 2 +- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Cmdlets/TestSupportedTextMate.cs b/src/Cmdlets/TestSupportedTextMate.cs index 042dbbb..5a3cee3 100644 --- a/src/Cmdlets/TestSupportedTextMate.cs +++ b/src/Cmdlets/TestSupportedTextMate.cs @@ -1,5 +1,5 @@ -using System.Management.Automation; -using System.Runtime.InteropServices; +using System.IO; +using System.Management.Automation; using TextMateSharp.Grammars; namespace PSTextMate.Commands; @@ -47,9 +47,11 @@ public sealed class TestSupportedTextMateCmdlet : PSCmdlet { protected override void EndProcessing() { switch (ParameterSetName) { case "FileSet": - FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(File)); + FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(File!)); if (!filePath.Exists) { - WriteError(new ErrorRecord(null, "TestSupportedTextMateCmdlet", ErrorCategory.ObjectNotFound, File)); + var exception = new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); + WriteError(new ErrorRecord(exception, nameof(TestSupportedTextMateCmdlet), ErrorCategory.ObjectNotFound, filePath.FullName)); + return; } WriteObject(TextMateExtensions.IsSupportedFile(filePath.FullName)); break; diff --git a/src/Rendering/TableRenderer.cs b/src/Rendering/TableRenderer.cs index 58c1eb7..6f39bca 100644 --- a/src/Rendering/TableRenderer.cs +++ b/src/Rendering/TableRenderer.cs @@ -38,10 +38,11 @@ internal static class TableRenderer { return null; // Add headers if present - (bool isHeader, List cells) headerRow = allRows.FirstOrDefault(r => r.isHeader); - if (headerRow.cells?.Count > 0) { - for (int i = 0; i < headerRow.cells.Count; i++) { - TableCellContent cell = headerRow.cells[i]; + int headerRowIndex = allRows.FindIndex(r => r.isHeader); + if (headerRowIndex >= 0 && allRows[headerRowIndex].cells.Count > 0) { + List headerCells = allRows[headerRowIndex].cells; + for (int i = 0; i < headerCells.Count; i++) { + TableCellContent cell = headerCells[i]; // Use constructor to set header text; this is the most compatible way var column = new TableColumn(cell.Text); // Apply alignment if Markdig specified one for the column @@ -58,10 +59,10 @@ internal static class TableRenderer { } else { // No explicit headers, use first row as headers - (bool isHeader, List cells) = allRows.FirstOrDefault(); - if (cells?.Count > 0) { - for (int i = 0; i < cells.Count; i++) { - TableCellContent cell = cells[i]; + List firstRowCells = allRows[0].cells; + if (firstRowCells.Count > 0) { + for (int i = 0; i < firstRowCells.Count; i++) { + TableCellContent cell = firstRowCells[i]; var column = new TableColumn(cell.Text); if (i < table.ColumnDefinitions.Count) { column.Alignment = table.ColumnDefinitions[i].Alignment switch { @@ -78,10 +79,10 @@ internal static class TableRenderer { } // Add data rows - foreach ((bool isHeader, List? cells) in allRows.Where(r => !r.isHeader)) { - if (cells?.Count > 0) { + foreach ((bool isHeader, List cells) in allRows.Where(r => !r.isHeader)) { + if (cells.Count > 0) { var rowCells = new List(); - foreach (TableCellContent? cell in cells) { + foreach (TableCellContent cell in cells) { Style cellStyle = GetCellStyle(theme); rowCells.Add(new Text(cell.Text, cellStyle)); } diff --git a/src/Utilities/TokenStyleProcessor.cs b/src/Utilities/TokenStyleProcessor.cs index 917c738..1010fe0 100644 --- a/src/Utilities/TokenStyleProcessor.cs +++ b/src/Utilities/TokenStyleProcessor.cs @@ -56,7 +56,7 @@ public static IRenderable[] ProcessLines( ITextMateStyler styler) { var result = new List(); - foreach ((IToken[]? tokens, string? line) in tokenizedLines) { + foreach ((IToken[] tokens, string line) in tokenizedLines) { // Process each line IRenderable[] lineRenderables = ProcessTokens(tokens, line, theme, styler); diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 index d15fbf7..5afb044 100644 --- a/tests/testhelper.psm1 +++ b/tests/testhelper.psm1 @@ -18,7 +18,7 @@ function _GetSpectreRenderable { $writer.ToString() } finally { - $writer.Dispose() + ${writer}?.Dispose() } } filter _EscapeAnsi {