diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..37222d5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,167 @@ +# 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 + +# Code files +[*.{cs,csx,vb,vbx}] +indent_style = space +indent_size = 4 + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_style = space +indent_size = 2 + +# XML files +[*.{xml,stylecop,resx,ruleset}] +indent_style = space +indent_size = 2 + +# JSON files +[*.{json,json5,webmanifest}] +indent_style = space +indent_size = 2 + +# YAML files +[*.{yml,yaml}] +indent_style = space +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 + +# Bash scripts +[*.sh] +end_of_line = lf + +# PowerShell files +[*.{ps1,psm1,psd1}] +indent_style = space +indent_size = 4 + +# C# code style rules +[*.cs] + +# Organize usings +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 +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 + +# Indentation preferences +csharp_indent_case_contents = 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 + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = suggestion + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = none diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b361f07 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,142 @@ + + +--- +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 5bbced6..8746b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ src/obj/* src/.vs/* Module/lib debug.md +output/* +*/obj/* +*/bin/* diff --git a/Module/PSTextMate.format.ps1xml b/Module/PSTextMate.format.ps1xml index e5c7d11..af6082c 100644 --- a/Module/PSTextMate.format.ps1xml +++ b/Module/PSTextMate.format.ps1xml @@ -63,7 +63,7 @@ - return 'Line {0}, Index {1}' -f $_.Index[0], ($_.Index[1,2] -join '-') + return 'Line {0}, Index {1}' -f $_.LineIndex, ($_.StartIndex, $_.EndIndex -join '-') @@ -82,7 +82,7 @@ TextMateDebug - PwshSpectreConsole.TextMate.Test+TokenDebug + PwshSpectreConsole.TextMate.Core.TokenDebugInfo diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index bf2cdfa..5091b1e 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -7,9 +7,9 @@ Copyright = '(c) trackd. All rights reserved.' PowerShellVersion = '7.4' CompatiblePSEditions = 'Core' - CmdletsToExport = 'Show-TextMate', 'Test-SupportedTextMate', 'Get-SupportedTextMate', 'Debug-TextMate', 'Debug-TextMateTokens' + CmdletsToExport = 'Show-TextMate', 'Test-SupportedTextMate', 'Get-SupportedTextMate', 'Debug-TextMate', 'Debug-TextMateTokens','Debug-SixelSupport','Test-ImageRendering' AliasesToExport = '*' - RequiredAssemblies = './lib/TextMateSharp.dll', './lib/TextMateSharp.Grammars.dll', './lib/Onigwrap.dll' + RequiredAssemblies = './lib/TextMateSharp.dll', './lib/TextMateSharp.Grammars.dll', './lib/Onigwrap.dll', 'Markdig.Signed.dll' FormatsToProcess = 'PSTextMate.format.ps1xml' RequiredModules = @( @{ diff --git a/build.ps1 b/build.ps1 index 2939349..6d85a34 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,30 +1,40 @@ +#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 -$moduleLibFolder = Join-Path $PSScriptRoot 'Module' 'lib' +$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 $PSScriptRoot 'src' 'PSTextMate.csproj') -$outputfolder = Join-Path $PSScriptRoot 'packages' +$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 $outputfolder 'runtimes' 'win-x64' 'native') -Filter *.dll | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path $outputfolder 'runtimes' 'osx' 'native') -Filter *.dylib | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path $outputfolder 'runtimes' 'linux-x64' 'native') -Filter *.so | Copy-Item -Destination $moduleLibFolder -Force -Move-Item (Join-Path $outputfolder 'PSTextMate.dll') -Destination (Split-Path $moduleLibFolder) -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' } | Move-Item -Destination $moduleLibFolder -Force diff --git a/src/Cmdlet/PSTextMateCmdlet.cs b/src/Cmdlet/PSTextMateCmdlet.cs deleted file mode 100644 index 948851f..0000000 --- a/src/Cmdlet/PSTextMateCmdlet.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System.IO; -using System.Collections.Generic; -using System.Management.Automation; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate; - -[Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "String")] -[Alias("st")] -public sealed class ShowTextMateCmdlet : PSCmdlet -{ - private readonly List _inputObjectBuffer = new(); - - [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String")] - [AllowEmptyString] - public string InputObject { get; set; } = null!; - - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Path", Position = 0)] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string Path { get; set; } = null!; - - [Parameter(ParameterSetName = "String")] - [ValidateSet(typeof(TextMateLanguages))] - public string Language { get; set; } = "powershell"; - - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.Dark; - - [Parameter(ParameterSetName = "Path")] - [TextMateExtensionTransform()] - [ValidateSet(typeof(TextMateExtensions))] - [Alias("As")] - public string ExtensionOverride { get; set; } = null!; - - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && null != InputObject) - { - _inputObjectBuffer.Add(InputObject); - } - } - protected override void EndProcessing() - { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) - { - string[] strings = _inputObjectBuffer.ToArray(); - // if all strings are empty, don't bother - if (Converter.AllIsNullOrEmpty(strings)) - { - return; - } - var rows = Converter.String(strings, Theme, Language); - WriteObject(rows); - } - else if (ParameterSetName == "Path" && null != Path) - { - FileInfo Filepath = new(GetUnresolvedProviderPathFromPSPath(Path)); - if (!Filepath.Exists) - { - throw new FileNotFoundException("File not found", Filepath.FullName); - } - // extension override, it decides the grammar to use for highlighting - string ext = !string.IsNullOrEmpty(ExtensionOverride) - ? ExtensionOverride - : Filepath.Extension; - var rows = Converter.ReadFile(Filepath.FullName, Theme, ext); - WriteObject(rows); - } - } -} - -[Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate")] -public sealed class TestTextMateCmdlet : PSCmdlet -{ - [Parameter()] - public string? Extension { get; set; } - - [Parameter()] - public string? Language { get; set; } - - [Parameter()] - public string? File { get; set; } - - 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)); - } - } -} - -[OutputType(typeof(Language))] -[Cmdlet(VerbsCommon.Get, "SupportedTextMate")] -public sealed class GetTextMateCmdlet : PSCmdlet -{ - protected override void EndProcessing() - { - WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); - } -} - -[Cmdlet(VerbsDiagnostic.Debug, "TextMate", DefaultParameterSetName = "String")] -public sealed class DebugTextMateCmdlet : PSCmdlet -{ - private readonly List _inputObjectBuffer = new(); - - [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String")] - [AllowEmptyString] - public string InputObject { get; set; } = null!; - - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Path", Position = 0)] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string Path { get; set; } = null!; - - [Parameter(ParameterSetName = "String")] - [ValidateSet(typeof(TextMateLanguages))] - public string Language { get; set; } = "powershell"; - - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.Dark; - - [Parameter(ParameterSetName = "Path")] - [TextMateExtensionTransform()] - [ValidateSet(typeof(TextMateExtensions))] - [Alias("As")] - public string ExtensionOverride { get; set; } = null!; - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && null != InputObject) - { - _inputObjectBuffer.Add(InputObject); - } - } - protected override void EndProcessing() - { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) - { - string[] strings = _inputObjectBuffer.ToArray(); - if (Converter.AllIsNullOrEmpty(strings)) - { - return; - } - var obj = Test.DebugTextMate(strings, Theme, Language); - WriteObject(obj, true); - } - else if (ParameterSetName == "Path" && null != Path) - { - 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); - var obj = Test.DebugTextMate(strings, Theme, ext, true); - WriteObject(obj, true); - } - } -} - -[Cmdlet(VerbsDiagnostic.Debug, "TextMateTokens", DefaultParameterSetName = "String")] -public sealed class DebugTextMateTokensCmdlet : PSCmdlet -{ - private readonly List _inputObjectBuffer = new(); - - [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String")] - [AllowEmptyString] - public string InputObject { get; set; } = null!; - - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Path", Position = 0)] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string Path { get; set; } = null!; - - [Parameter(ParameterSetName = "String")] - [ValidateSet(typeof(TextMateLanguages))] - public string Language { get; set; } = "powershell"; - - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.Dark; - - [Parameter(ParameterSetName = "Path")] - [TextMateExtensionTransform()] - [ValidateSet(typeof(TextMateExtensions))] - [Alias("As")] - public string ExtensionOverride { get; set; } = null!; - - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && null != InputObject) - { - _inputObjectBuffer.Add(InputObject); - } - } - protected override void EndProcessing() - { - if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) - { - string[] strings = _inputObjectBuffer.ToArray(); - if (Converter.AllIsNullOrEmpty(strings)) - { - return; - } - var obj = Test.DebugTextMateTokens(strings, Theme, Language); - WriteObject(obj, true); - - } - else if (ParameterSetName == "Path" && null != Path) - { - 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); - var obj = Test.DebugTextMateTokens(strings, Theme, ext, true); - WriteObject(obj, true); - } - } -} diff --git a/src/Cmdlets/DebugCmdlets.cs b/src/Cmdlets/DebugCmdlets.cs new file mode 100644 index 0000000..3b0558c --- /dev/null +++ b/src/Cmdlets/DebugCmdlets.cs @@ -0,0 +1,250 @@ +using System.Management.Automation; +using TextMateSharp.Grammars; +using PwshSpectreConsole.TextMate.Extensions; +using PwshSpectreConsole.TextMate.Core; +using Spectre.Console.Rendering; + +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")] +public sealed class DebugTextMateCmdlet : PSCmdlet +{ + private readonly List _inputObjectBuffer = new(); + + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ParameterSetName = "String" + )] + [AllowEmptyString] + public string InputObject { get; set; } = null!; + + [Parameter( + Mandatory = true, + ValueFromPipelineByPropertyName = true, + ParameterSetName = "Path", + Position = 0 + )] + [ValidateNotNullOrEmpty] + [Alias("FullName")] + public string Path { get; set; } = null!; + + [Parameter( + ParameterSetName = "String" + )] + [ValidateSet(typeof(TextMateLanguages))] + public string Language { get; set; } = "powershell"; + + [Parameter()] + public ThemeName Theme { get; set; } = ThemeName.Dark; + + [Parameter( + ParameterSetName = "Path" + )] + [TextMateExtensionTransform()] + [ValidateSet(typeof(TextMateExtensions))] + [Alias("As")] + public string ExtensionOverride { get; set; } = null!; + + protected override void ProcessRecord() + { + if (ParameterSetName == "String" && InputObject is not null) + { + _inputObjectBuffer.Add(InputObject); + } + } + + protected override void EndProcessing() + { + try + { + if (ParameterSetName == "String" && _inputObjectBuffer.Count > 0) + { + string[] strings = _inputObjectBuffer.ToArray(); + 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. +/// +[Cmdlet(VerbsDiagnostic.Debug, "TextMateTokens", DefaultParameterSetName = "String")] +public sealed class DebugTextMateTokensCmdlet : PSCmdlet +{ + private readonly List _inputObjectBuffer = new(); + + [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "String")] + [AllowEmptyString] + public string InputObject { get; set; } = null!; + + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Path", Position = 0)] + [ValidateNotNullOrEmpty] + [Alias("FullName")] + public string Path { get; set; } = null!; + + [Parameter(ParameterSetName = "String")] + [ValidateSet(typeof(TextMateLanguages))] + public string Language { get; set; } = "powershell"; + + [Parameter()] + public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + + [Parameter(ParameterSetName = "Path")] + [TextMateExtensionTransform()] + [ValidateSet(typeof(TextMateExtensions))] + [Alias("As")] + public string ExtensionOverride { get; set; } = null!; + + protected override void ProcessRecord() + { + if (ParameterSetName == "String" && InputObject is not null) + { + _inputObjectBuffer.Add(InputObject); + } + } + + 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 +{ + 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, + Version = a.GetName().Version?.ToString(), + Location = 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 +{ + [Parameter(Mandatory = true, Position = 0)] + public string ImageUrl { get; set; } = null!; + + [Parameter()] + public string AltText { get; set; } = "Test Image"; + + 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/Cmdlets/ShowTextMateCmdlet.cs new file mode 100644 index 0000000..10cf2d1 --- /dev/null +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -0,0 +1,165 @@ +using System.Management.Automation; +using TextMateSharp.Grammars; +using PwshSpectreConsole.TextMate.Extensions; +using Spectre.Console; + +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")] +[Alias("st","Show-Code")] +[OutputType(typeof(Rows))] +public sealed class ShowTextMateCmdlet : PSCmdlet +{ + private static readonly string[] NewLineSplit = ["\r\n", "\n", "\r"]; + private readonly List _inputObjectBuffer = []; + + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ParameterSetName = "String" + )] + [AllowEmptyString] + [ValidateNotNull] + public object? InputObject { get; set; } + + [Parameter( + Mandatory = true, + ValueFromPipelineByPropertyName = true, + ParameterSetName = "Path", + Position = 0 + )] + [ValidateNotNullOrEmpty] + [Alias("FullName")] + public string? Path { get; set; } + + [Parameter( + ParameterSetName = "String" + )] + [ValidateSet(typeof(TextMateLanguages))] + public string? Language { get; set; } = "powershell"; + + [Parameter()] + public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + + [Parameter( + ParameterSetName = "Path" + )] + [TextMateExtensionTransform()] + [ValidateSet(typeof(TextMateExtensions))] + [Alias("As")] + public string? ExtensionOverride { get; set; } + + [Parameter] + public SwitchParameter PassThru { get; set; } + + protected override void BeginProcessing() + { + // Validate language support early + if (ParameterSetName == "String" && !TextMateLanguages.IsSupportedLanguage(Language!)) + { + WriteWarning($"Language '{Language}' may not be fully supported. Use Get-SupportedTextMate to see available languages."); + } + } + + protected override void ProcessRecord() + { + if (ParameterSetName == "String" && InputObject is not null) + { + object baseObj = InputObject; + // Unwrap PSObject if needed + if (baseObj is PSObject pso) + { + baseObj = pso.BaseObject; + } + switch (baseObj) + { + case string s: + _inputObjectBuffer.Add(s); + break; + case string[] arr: + _inputObjectBuffer.AddRange(arr); + break; + case IEnumerable enumerable: + _inputObjectBuffer.AddRange(enumerable); + break; + default: + WriteWarning($"InputObject of type '{baseObj.GetType().Name}' is not supported. Only string and string[] are accepted."); + break; + } + } + } + + protected override void EndProcessing() + { + try + { + Rows? result = ParameterSetName switch + { + "String" => ProcessStringInput(), + "Path" => ProcessPathInput(), + _ => throw new InvalidOperationException($"Unknown parameter set: {ParameterSetName}") + }; + + if (result is not null) + { + WriteObject(result); + + if (PassThru) + { + WriteVerbose($"Processed {(ParameterSetName == "String" ? _inputObjectBuffer.Count : "file")} lines with theme '{Theme}' and {(ParameterSetName == "String" ? $"language '{Language}'" : "extension detection")}"); + } + } + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); + } + } + + private Rows? ProcessStringInput() + { + if (_inputObjectBuffer.Count == 0) + { + WriteVerbose("No input provided"); + return null; + } + + string[] strings = _inputObjectBuffer.ToArray(); + // 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()) + { + WriteVerbose("All input strings are null or empty"); + return null; + } + + return Converter.ProcessLines(strings, Theme, Language ?? "powershell", isExtension: false); + } + + private Rows? ProcessPathInput() + { + FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(Path)); + + if (!filePath.Exists) + { + throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); + } + + string extension = !string.IsNullOrEmpty(ExtensionOverride) + ? ExtensionOverride + : filePath.Extension; + + WriteVerbose($"Processing file: {filePath.FullName} with extension: {extension}"); + + // Read file in cmdlet and pass to unified ProcessLines method + string[] lines = File.ReadAllLines(filePath.FullName); + return Converter.ProcessLines(lines, Theme, extension, isExtension: true); + } +} diff --git a/src/Cmdlets/SupportCmdlets.cs b/src/Cmdlets/SupportCmdlets.cs new file mode 100644 index 0000000..6ee5f6e --- /dev/null +++ b/src/Cmdlets/SupportCmdlets.cs @@ -0,0 +1,51 @@ +using System.Management.Automation; +using TextMateSharp.Grammars; + +namespace PwshSpectreConsole.TextMate.Cmdlets; + +/// +/// 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 +{ + [Parameter()] + public string? Extension { get; set; } + + [Parameter()] + public string? Language { get; set; } + + [Parameter()] + public string? File { get; set; } + + 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 +{ + protected override void EndProcessing() + { + WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); + } +} diff --git a/src/Compatibility/Converter.cs b/src/Compatibility/Converter.cs new file mode 100644 index 0000000..46c4035 --- /dev/null +++ b/src/Compatibility/Converter.cs @@ -0,0 +1,13 @@ +using PwshSpectreConsole.TextMate.Core; +using Spectre.Console; +using TextMateSharp.Grammars; + +namespace PwshSpectreConsole.TextMate; + +public static class Converter +{ + public static Rows? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) + { + return TextMateProcessor.ProcessLines(lines, themeName, grammarId, isExtension); + } +} diff --git a/src/Core/MarkdigSpectreMarkdownRenderer.cs b/src/Core/MarkdigSpectreMarkdownRenderer.cs new file mode 100644 index 0000000..7a17047 --- /dev/null +++ b/src/Core/MarkdigSpectreMarkdownRenderer.cs @@ -0,0 +1,25 @@ +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/MarkdigTextMateScopeMapper.cs b/src/Core/MarkdigTextMateScopeMapper.cs new file mode 100644 index 0000000..f127a34 --- /dev/null +++ b/src/Core/MarkdigTextMateScopeMapper.cs @@ -0,0 +1,66 @@ +namespace PwshSpectreConsole.TextMate.Core; + +/// +/// Maps Markdig markdown element types to TextMate scopes for theme lookup. +/// +internal static class MarkdigTextMateScopeMapper +{ + private static readonly Dictionary BlockScopeMap = new() + { + { "Heading1", new[] { "markup.heading.1.markdown", "markup.heading.markdown" } }, + { "Heading2", new[] { "markup.heading.2.markdown", "markup.heading.markdown" } }, + { "Heading3", new[] { "markup.heading.3.markdown", "markup.heading.markdown" } }, + { "Heading4", new[] { "markup.heading.4.markdown", "markup.heading.markdown" } }, + { "Heading5", new[] { "markup.heading.5.markdown", "markup.heading.markdown" } }, + { "Heading6", new[] { "markup.heading.6.markdown", "markup.heading.markdown" } }, + { "Paragraph", new[] { "markup.paragraph.markdown", "text.plain" } }, + { "List", new[] { "markup.list.markdown" } }, + { "ListItem", new[] { "markup.list.markdown" } }, + { "Table", new[] { "markup.table.markdown" } }, + { "TableRow", new[] { "markup.table.row.markdown" } }, + { "TableCell", new[] { "markup.table.cell.markdown" } }, + { "Quote", new[] { "markup.quote.markdown" } }, + { "ThematicBreak", new[] { "meta.separator.markdown" } }, + { "CodeBlock", new[] { "markup.raw.block.markdown" } }, + { "HtmlBlock", new[] { "markup.raw.block.html.markdown" } }, + { "TaskList", new[] { "markup.list.task.markdown" } }, + }; + + private static readonly Dictionary InlineScopeMap = new() + { + { "EmphasisItalic", new[] { "markup.italic.markdown" } }, + { "EmphasisBold", new[] { "markup.bold.markdown" } }, + { "EmphasisBoldItalic", new[] { "markup.bold.markdown", "markup.italic.markdown" } }, + { "Link", new[] { "markup.underline.link.markdown" } }, + { "Image", new[] { "markup.underline.link.image.markdown" } }, + { "CodeInline", new[] { "markup.inline.raw.markdown" } }, + { "Literal", new[] { "text.plain" } }, + { "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[] GetInlineScopes(string inlineType, int emphasisLevel = 0) + { + if (inlineType == "Emphasis") + { + return 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"]; + } +} diff --git a/src/Core/Markdown/InlineProcessor.cs b/src/Core/Markdown/InlineProcessor.cs new file mode 100644 index 0000000..2c16ed3 --- /dev/null +++ b/src/Core/Markdown/InlineProcessor.cs @@ -0,0 +1,166 @@ +using System.Text; +using Markdig.Syntax.Inlines; +using PwshSpectreConsole.TextMate.Core.Helpers; +using PwshSpectreConsole.TextMate.Extensions; +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)) + { + var linkBuilder = new StringBuilder(); + ExtractInlineText(link, theme, linkBuilder); + + if (link.IsImage) + { + ProcessImageLink(linkBuilder.ToString(), link.Url, theme, builder); + } + else + { + builder.AppendLink(link.Url, linkBuilder.ToString()); + } + } + 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); + + var emphBuilder = new StringBuilder(); + ExtractInlineText(emph, theme, emphBuilder); + + // Apply the theme colors/style to the emphasis text + if (efg != -1 || ebg != -1 || efStyle != TextMateSharp.Themes.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); + builder.AppendWithStyle(emphStyle, emphBuilder.ToString()); + } + else + { + builder.Append(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 != TextMateSharp.Themes.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/MarkdownRenderer.cs b/src/Core/Markdown/MarkdownRenderer.cs new file mode 100644 index 0000000..caf3eb4 --- /dev/null +++ b/src/Core/Markdown/MarkdownRenderer.cs @@ -0,0 +1,91 @@ +using Markdig; +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. +/// +internal static class MarkdownRenderer +{ + /// + /// 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 + /// Rows object for Spectre.Console rendering + 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++) + { + 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) + { + // Add spacing before certain block types or when there was previous content + bool needsSpacing = ShouldAddSpacing(block, lastWasContent); + + if (needsSpacing && rows.Count > 0) + { + rows.Add(Text.Empty); + } + + rows.Add(renderable); + lastWasContent = true; + } + else + { + lastWasContent = false; + } + } + + return new Rows([.. rows]); + } + + /// + /// Creates the Markdig pipeline with all necessary extensions enabled. + /// + /// Configured MarkdownPipeline + private static MarkdownPipeline CreateMarkdownPipeline() + { + return new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UsePipeTables() + .UseEmphasisExtras() + .UseAutoLinks() + .UseTaskLists() + .EnableTrackTrivia() // Enable HTML support + .Build(); + } + + /// + /// Determines if spacing should be added before a block element. + /// + /// 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; + } +} diff --git a/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs b/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs new file mode 100644 index 0000000..55da49d --- /dev/null +++ b/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs @@ -0,0 +1,223 @@ +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 new file mode 100644 index 0000000..d3fe0c8 --- /dev/null +++ b/src/Core/Markdown/README.md @@ -0,0 +1,125 @@ +# 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 + +``` +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/BlockRenderer.cs b/src/Core/Markdown/Renderers/BlockRenderer.cs new file mode 100644 index 0000000..bcdc99f --- /dev/null +++ b/src/Core/Markdown/Renderers/BlockRenderer.cs @@ -0,0 +1,44 @@ +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +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 +{ + /// + /// Routes block elements to their appropriate renderers. + /// All renderers build Spectre.Console objects directly instead of markup strings. + /// + /// 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) + { + return block switch + { + // 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), + + // Keep existing renderers for remaining complex blocks + QuoteBlock quote => QuoteRenderer.Render(quote, theme), + HtmlBlock html => HtmlBlockRenderer.Render(html, theme, themeName), + ThematicBreakBlock => HorizontalRuleRenderer.Render(), + + // Unsupported block types + _ => null + }; + } +} diff --git a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs new file mode 100644 index 0000000..d3e190c --- /dev/null +++ b/src/Core/Markdown/Renderers/CodeBlockRenderer.cs @@ -0,0 +1,243 @@ +using System.Buffers; +using System.Text; +using Markdig.Syntax; +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; + +/// +/// Code block renderer that builds Spectre.Console objects directly +/// and fixes whitespace and detection issues. +/// +internal static class CodeBlockRenderer +{ + // Cached SearchValues for improved performance + private static readonly SearchValues LanguageDelimiters = SearchValues.Create([' ', '\t', '{', '}', '(', ')', '[', ']']); + + /// + /// Renders a fenced code block with proper whitespace handling and language detection. + /// + /// The fenced code block to render + /// Theme for styling + /// Theme name for TextMateProcessor + /// 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); + + if (!string.IsNullOrEmpty(language)) + { + try + { + Rows? rows = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); + if (rows is not null) + { + return new Panel(rows) + .Border(BoxBorder.Rounded) + .Header(language, Justify.Left); + } + } + catch + { + // Fallback to plain rendering + } + } + + // 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) + { + string[] codeLines = ExtractCodeLinesFromStringLineGroup(code.Lines); + return CreateOptimizedCodePanel(codeLines, "code", theme); + } + + /// + /// Extracts code lines with simple and safe processing to avoid bounds issues. + /// + private static string[] ExtractCodeLinesWithWhitespaceHandling(Markdig.Helpers.StringLineGroup lines) + { + if (lines.Count == 0) + return []; + + var codeLines = new List(lines.Count); + + foreach (StringLine line in lines.Lines) + { + try + { + // Use the safest approach: let the slice handle its own bounds + string lineText = line.Slice.ToString(); + + // Simple trailing whitespace trimming without spans + lineText = lineText.TrimEnd(); + + codeLines.Add(lineText); + } + 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()); + } /// + /// Extracts code lines from a string line group (for indented code blocks). + /// + private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.StringLineGroup lines) + { + if (lines.Count == 0) + return []; + + string content = lines.ToString(); + if (string.IsNullOrEmpty(content)) + return []; + + // Split into lines and handle whitespace properly + string[] splitLines = content.Split(['\r', '\n'], StringSplitOptions.None); + + // Process each line to handle whitespace correctly + for (int i = 0; i < splitLines.Length; i++) + { + splitLines[i] = TrimTrailingWhitespace(splitLines[i].AsSpan()).ToString(); + } + + return RemoveTrailingEmptyLines(splitLines); + } + + /// + /// Improved language extraction with better detection patterns. + /// + private static string ExtractLanguageImproved(string? info) + { + if (string.IsNullOrWhiteSpace(info)) + return string.Empty; + + ReadOnlySpan infoSpan = info.AsSpan().Trim(); + + // Handle various language specification formats + // Examples: "csharp", "c#", "python copy", "javascript {1-3}", etc. + + // Find first whitespace or special character to extract just the language + int endIndex = infoSpan.IndexOfAny(LanguageDelimiters); + if (endIndex >= 0) + { + infoSpan = infoSpan[..endIndex]; + } + + string language = infoSpan.ToString().ToLowerInvariant(); + + // Handle common language aliases and improve detection + return NormalizeLanguageName(language); + } + + /// + /// Normalizes language names to improve code block detection. + /// + private static string NormalizeLanguageName(string language) + { + return language switch + { + "c#" or "csharp" or "cs" => "csharp", + "js" or "javascript" => "javascript", + "ts" or "typescript" => "typescript", + "py" or "python" => "python", + "ps1" or "powershell" or "pwsh" => "powershell", + "sh" or "bash" => "bash", + "yml" or "yaml" => "yaml", + "md" or "markdown" => "markdown", + "json" => "json", + "xml" => "xml", + "html" => "html", + "css" => "css", + "sql" => "sql", + "dockerfile" => "dockerfile", + _ => language + }; + } + + /// + /// Trims only trailing whitespace while preserving leading whitespace for indentation. + /// + private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line) + { + int end = line.Length; + while (end > 0 && char.IsWhiteSpace(line[end - 1])) + { + end--; + } + return line[..end]; + } + + /// + /// Removes trailing empty lines that cause unnecessary whitespace in code blocks. + /// + 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])) + { + lastNonEmptyIndex--; + } + + // If all lines are empty, return a single empty line + if (lastNonEmptyIndex < 0) + return [string.Empty]; + + // Return array up to the last non-empty line + if (lastNonEmptyIndex == lines.Length - 1) + return lines; // No trailing empty lines to remove + + string[] result = new string[lastNonEmptyIndex + 1]; + Array.Copy(lines, result, lastNonEmptyIndex + 1); + return result; + } + + /// + /// 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) + { + // Get theme colors for code blocks + string[] codeScopes = new[] { "text.html.markdown", "markup.fenced_code.block.markdown" }; + (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( + new MarkdownToken(codeScopes), theme); + + // Create code styling + Color? foregroundColor = codeFg != -1 ? StyleHelper.GetColor(codeFg, theme) : Color.Grey; + Color? backgroundColor = codeBg != -1 ? StyleHelper.GetColor(codeBg, theme) : Color.Black; + Decoration decoration = StyleHelper.GetDecoration(codeFs); + var codeStyle = new Style(foregroundColor, backgroundColor, decoration); + + // Join lines efficiently + string codeText = string.Join('\n', codeLines); + + // Create Text object directly instead of Markup to avoid parsing issues + var codeContent = new Text(codeText, codeStyle); + + string headerText = !string.IsNullOrEmpty(language) ? language : "code"; + + return new Panel(codeContent) + .Border(BoxBorder.Rounded) + .Header(headerText, Justify.Left); + } +} diff --git a/src/Core/Markdown/Renderers/HeadingRenderer.cs b/src/Core/Markdown/Renderers/HeadingRenderer.cs new file mode 100644 index 0000000..63eeb33 --- /dev/null +++ b/src/Core/Markdown/Renderers/HeadingRenderer.cs @@ -0,0 +1,161 @@ +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +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 +{ + /// + /// Renders a heading block by building Spectre.Console Text objects directly. + /// This approach eliminates VT escaping issues and improves performance. + /// + /// 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) + { + // Extract heading text without building markup strings + string headingText = ExtractHeadingText(heading); + + // Get theme colors for heading styling + string[] headingScopes = MarkdigTextMateScopeMapper.GetBlockScopes("Heading", heading.Level); + (int hfg, int hbg, FontStyle hfs) = TokenProcessor.ExtractThemeProperties(new MarkdownToken(headingScopes), theme); + + // Build styling directly + Style headingStyle = CreateHeadingStyle(hfg, hbg, hfs, theme, heading.Level); + + // Return Text object directly - no markup parsing needed + return new Text(headingText, headingStyle); + } + + /// + /// Extracts plain text from heading inline elements without building markup. + /// + 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: + textBuilder.Append(literal.Content.ToString()); + break; + + case Markdig.Syntax.Inlines.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: + textBuilder.Append(code.Content); + break; + + case Markdig.Syntax.Inlines.LinkInline link: + // Extract link text, not the URL + ExtractInlineTextRecursive(link, textBuilder); + break; + + default: + ExtractInlineTextRecursive(inline, textBuilder); + break; + } + } + + return textBuilder.ToString(); + } + + /// + /// 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: + builder.Append(literal.Content.ToString()); + break; + + case Markdig.Syntax.Inlines.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) + { + builder.Append(code.Content); + } + 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) + { + Color? foregroundColor = null; + Color? backgroundColor = null; + Decoration decoration = Decoration.None; + + // Apply theme colors if available + if (foreground != -1) + { + foregroundColor = StyleHelper.GetColor(foreground, theme); + } + + if (background != -1) + { + backgroundColor = StyleHelper.GetColor(background, theme); + } + + // Apply font style decorations + decoration = StyleHelper.GetDecoration(fontStyle); + + // Apply level-specific styling as fallbacks + if (foregroundColor is null) + { + foregroundColor = GetDefaultHeadingColor(level); + } + + // Ensure headings are bold by default + if (decoration == Decoration.None) + { + decoration = Decoration.Bold; + } + + return new Style(foregroundColor ?? Color.Default, backgroundColor ?? Color.Default, decoration); + } + + /// + /// Gets default colors for heading levels when theme doesn't provide them. + /// + private static Color GetDefaultHeadingColor(int level) + { + return level switch + { + 1 => Color.Red, + 2 => Color.Orange1, + 3 => Color.Yellow, + 4 => Color.Green, + 5 => Color.Blue, + 6 => Color.Purple, + _ => Color.White + }; + } +} diff --git a/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs b/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs new file mode 100644 index 0000000..5a52a33 --- /dev/null +++ b/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs @@ -0,0 +1,19 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Renders markdown horizontal rules (thematic breaks). +/// +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")); + } +} diff --git a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs new file mode 100644 index 0000000..693a5e7 --- /dev/null +++ b/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs @@ -0,0 +1,72 @@ +using Markdig.Syntax; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Renders HTML blocks with syntax highlighting. +/// +internal static class HtmlBlockRenderer +{ + /// + /// Renders an HTML block with syntax highlighting when possible. + /// + /// The HTML block to render + /// Theme for styling + /// Theme name for TextMateProcessor + /// Rendered HTML block in a panel + public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName themeName) + { + List htmlLines = ExtractHtmlLines(htmlBlock); + + // Try to render with HTML syntax highlighting + try + { + Rows? htmlRows = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); + if (htmlRows is not null) + { + return new Panel(htmlRows) + .Border(BoxBorder.Rounded) + .Header("html", Justify.Left); + } + } + catch + { + // Fallback to plain rendering + } + + // Fallback: plain HTML panel + return CreateFallbackHtmlPanel(htmlLines); + } + + /// + /// Extracts HTML lines from the HTML block. + /// + private static List ExtractHtmlLines(HtmlBlock htmlBlock) + { + var htmlLines = new List(); + + for (int i = 0; i < htmlBlock.Lines.Count; i++) + { + Markdig.Helpers.StringLine line = htmlBlock.Lines.Lines[i]; + htmlLines.Add(line.Slice.ToString()); + } + + return htmlLines; + } + + /// + /// 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)) + .Border(BoxBorder.Rounded) + .Header("html", Justify.Left); + } +} diff --git a/src/Core/Markdown/Renderers/ImageRenderer.cs b/src/Core/Markdown/Renderers/ImageRenderer.cs new file mode 100644 index 0000000..a978ca9 --- /dev/null +++ b/src/Core/Markdown/Renderers/ImageRenderer.cs @@ -0,0 +1,426 @@ +using System.Reflection; +using PwshSpectreConsole.TextMate.Core.Helpers; +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; + +/// +/// Handles rendering of images in markdown using Sixel format when possible. +/// +internal static class ImageRenderer +{ + private static string? _lastSixelError; + private static string? _lastImageError; + private static readonly TimeSpan ImageTimeout = TimeSpan.FromSeconds(5); // Increased to 5 seconds + + /// + /// Renders an image using Sixel format if possible, otherwise falls back to a link. + /// + /// 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 + 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)) + { + _lastImageError = $"Unsupported image format: {imageUrl}"; + return CreateImageFallback(altText, imageUrl); + } + + // Use a timeout for image processing + string? localImagePath = null; + Task imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); + + if (imageTask.Wait(ImageTimeout)) + { + localImagePath = imageTask.Result; + } + else + { + // Timeout occurred + _lastImageError = $"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"; + return CreateImageFallback(altText, imageUrl); + } + + 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)) + { + _lastImageError = $"Downloaded image file does not exist: {localImagePath}"; + return CreateImageFallback(altText, imageUrl); + } + + var fileInfo = new FileInfo(localImagePath); + if (fileInfo.Length == 0) + { + _lastImageError = $"Downloaded image file is empty: {localImagePath} (0 bytes)"; + return CreateImageFallback(altText, imageUrl); + } + + // Set reasonable defaults for markdown display + 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) + { + return sixelImage; + } + 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) + { + // If anything goes wrong, fall back to the basic link representation + _lastImageError = $"Exception in RenderImage: {ex.Message}"; + return CreateImageFallback(altText, imageUrl); + } + } + + /// + /// Renders an image inline (without panel) using Sixel format if possible. + /// + /// 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 + 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)) + { + return CreateImageFallbackInline(altText, imageUrl); + } + + // Use a timeout for image processing + string? localImagePath = null; + Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); + + if (imageTask.Wait(ImageTimeout)) + { + localImagePath = imageTask.Result; + } + else + { + // Timeout occurred + return CreateImageFallbackInline(altText, imageUrl); + } + + if (localImagePath is null) + { + return CreateImageFallbackInline(altText, imageUrl); + } + + // Smaller defaults for inline images + 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) + { + return sixelImage; + } + else + { + // Fallback to inline link representation + return CreateImageFallbackInline(altText, imageUrl); + } + } + catch + { + // If anything goes wrong, fall back to the link representation + return CreateImageFallbackInline(altText, imageUrl); + } + } + + /// + /// Attempts to create a SixelImage using reflection for forward compatibility. + /// + /// 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) + { + result = null; + + 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"); + + // If that fails, search through loaded assemblies + if (sixelImageType is null) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + string? assemblyName = assembly.GetName().Name; + 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) + { + break; + } + } + } + } + + 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) + { + 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) + { + // Found some Sixel-related types, try the first one + sixelImageType = assembly.GetType(spectreTypes[0]!); + break; + } + } + } + } + + if (sixelImageType is null) + { + return false; + } + + // Create SixelImage instance + ConstructorInfo? constructor = sixelImageType.GetConstructor([typeof(string), typeof(bool)]); + if (constructor is null) + { + return false; + } + + object? sixelInstance = constructor.Invoke([imagePath, false]); // false = animation enabled + if (sixelInstance is null) + { + return false; + } + + // Apply size constraints if available + if (maxWidth.HasValue) + { + PropertyInfo? maxWidthProperty = sixelImageType.GetProperty("MaxWidth"); + if (maxWidthProperty is not null && maxWidthProperty.CanWrite) + { + maxWidthProperty.SetValue(sixelInstance, maxWidth.Value); + } + else + { + // Try method-based approach as fallback + MethodInfo? maxWidthMethod = sixelImageType.GetMethod("MaxWidth"); + if (maxWidthMethod is not null) + { + sixelInstance = maxWidthMethod.Invoke(sixelInstance, [maxWidth.Value]); + } + } + } + + if (maxHeight.HasValue) + { + PropertyInfo? maxHeightProperty = sixelImageType.GetProperty("MaxHeight"); + if (maxHeightProperty?.CanWrite == true) + { + maxHeightProperty.SetValue(sixelInstance, maxHeight.Value); + } + else + { + // Try method-based approach as fallback + MethodInfo? maxHeightMethod = sixelImageType.GetMethod("MaxHeight"); + if (maxHeightMethod is not null) + { + sixelInstance = maxHeightMethod.Invoke(sixelInstance, [maxHeight.Value]); + } + } + } + + if (sixelInstance is IRenderable renderable) + { + result = renderable; + return true; + } + } + catch (Exception ex) + { + // Capture the error for debugging + _lastSixelError = ex.Message; + } + + return false; + } + + /// + /// Creates a fallback representation of an image as a clickable link with an icon. + /// + /// 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); + } + + /// + /// Creates an enhanced fallback representation with file information. + /// + /// Alternative text for the image + /// 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); + 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)[/]") + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey); + } + catch + { + return CreateImageFallback(altText, imageUrl); + } + } + + /// + /// Creates an inline fallback representation of an image as a clickable link with an icon. + /// + /// 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); + } + + /// + /// 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) + { + return 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) + { + return 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; + } + + /// + /// Gets debug information about the last Sixel error. + /// + /// The last error message, if any + public static string? GetLastSixelError() + { + return _lastSixelError; + } + + /// + /// Checks if SixelImage type is available in the current environment. + /// + /// 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"); + + if (sixelImageType is not null) + return true; + + // Search through loaded assemblies + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + string? assemblyName = assembly.GetName().Name; + if (assemblyName?.Contains("Spectre.Console") == true) + { + sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); + if (sixelImageType is not null) + return true; + } + } + + return false; + } + catch + { + return false; + } + } + +} diff --git a/src/Core/Markdown/Renderers/ListRenderer.cs b/src/Core/Markdown/Renderers/ListRenderer.cs new file mode 100644 index 0000000..f770481 --- /dev/null +++ b/src/Core/Markdown/Renderers/ListRenderer.cs @@ -0,0 +1,266 @@ +using System.Text; +using Markdig.Extensions.TaskLists; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +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 +{ + private const string TaskCheckedEmoji = "✅ "; + private const string TaskUncheckedEmoji = "⬜ "; // More visible white square + private const string UnorderedBullet = "• "; + + /// + /// Renders a list block by building Spectre.Console objects directly. + /// This approach eliminates VT escaping issues and improves performance. + /// + /// The list block to render + /// Theme for styling + /// Rendered list as a Paragraph with proper styling + public static IRenderable Render(ListBlock list, Theme theme) + { + var paragraph = new Paragraph(); + 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); + + // 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); + + // Extract and append the item content directly as styled text + AppendListItemContent(paragraph, item, theme); + + isFirstItem = false; + } + + return paragraph; + } + + /// + /// 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) + { + return (true, taskList.Checked); + } + } + } + + return (false, false); + } + + /// + /// 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; + } + } + + /// + /// 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. + /// + 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; + + case CodeBlock subCode: + string codeText = subCode.Lines.ToString(); + paragraph.Append(codeText, Style.Plain); + 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); + } + break; + } + } + } + + /// + /// 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) + { + 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) + { + var builder = new StringBuilder(); + ExtractInlineTextRecursive(inline, builder); + return builder.ToString(); + } + + /// + /// Recursively extracts text from inline elements. + /// + private static void ExtractInlineTextRecursive(Inline inline, StringBuilder builder) + { + switch (inline) + { + case Markdig.Syntax.Inlines.LiteralInline literal: + builder.Append(literal.Content.ToString()); + break; + + case Markdig.Syntax.Inlines.ContainerInline container: + foreach (Inline child in container) + { + ExtractInlineTextRecursive(child, builder); + } + break; + + case Markdig.Syntax.Inlines.LeafInline leaf: + // For leaf inlines like CodeInline, extract their content + if (leaf is Markdig.Syntax.Inlines.CodeInline code) + { + builder.Append(code.Content); + } + break; + } + } + + /// + /// Renders nested lists as indented text content. + /// + private static string RenderNestedListAsText(ListBlock list, Theme theme, int indentLevel) + { + var builder = new StringBuilder(); + string indent = new string(' ', indentLevel * 2); + int number = 1; + bool isFirstItem = true; + + foreach (ListItemBlock item in list) + { + if (!isFirstItem) + builder.Append('\n'); + + builder.Append(indent); + + (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); + } + + // Extract item text without complex inline processing for nested items + string itemText = ExtractListItemTextSimple(item); + builder.Append(itemText.Trim()); + + isFirstItem = false; + } + + 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)); + } + } + } + else if (subBlock is CodeBlock subCode) + { + builder.Append(subCode.Lines.ToString()); + } + } + + return builder.ToString(); + } +} diff --git a/src/Core/Markdown/Renderers/ParagraphRenderer.cs b/src/Core/Markdown/Renderers/ParagraphRenderer.cs new file mode 100644 index 0000000..4e1a3c2 --- /dev/null +++ b/src/Core/Markdown/Renderers/ParagraphRenderer.cs @@ -0,0 +1,408 @@ +using System.Text.RegularExpressions; +using Markdig.Extensions.AutoLinks; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +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 +{ + /// + /// Renders a paragraph block by building Spectre.Console Paragraph objects directly. + /// This approach eliminates VT escaping issues and improves performance. + /// + /// 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 spectreConsole = new Paragraph(); + + if (paragraph.Inline is not null) + { + ProcessInlineElements(spectreConsole, paragraph.Inline, theme); + } + + return spectreConsole; + } + + /// + /// 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) + { + 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) + { + // 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); + } + else + { + paragraph.Append(segment.Text, Style.Plain); + } + } + } + else + { + paragraph.Append(literalText, Style.Plain); + } + break; + + case EmphasisInline emphasis: + ProcessEmphasisInline(paragraph, emphasis, theme); + break; + + case CodeInline code: + ProcessCodeInline(paragraph, code, theme); + break; + + case LinkInline link: + ProcessLinkInline(paragraph, link, theme); + break; + + case AutolinkInline autoLink: + ProcessAutoLinkInline(paragraph, autoLink, theme); + break; + + case Markdig.Extensions.TaskLists.TaskList taskList: + // TaskList items are handled at the list level, skip here + break; + + case LineBreakInline: + paragraph.Append("\n", Style.Plain); + break; + + case HtmlInline html: + // For HTML inlines, just extract the text content + string htmlText = html.Tag ?? ""; + paragraph.Append(htmlText, Style.Plain); + break; + + default: + // Fallback for unknown inline types - just write text as-is + string defaultText = ExtractInlineText(inline); + paragraph.Append(defaultText, Style.Plain); + break; + } + } + } + + /// + /// Processes emphasis (bold/italic) inline elements while preserving nested links. + /// + 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 + }; + + // Process children while applying emphasis decoration + ProcessInlineElementsWithDecoration(paragraph, emphasis, decoration, theme); + } + + /// + /// 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) + { + 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; + + default: + // Fallback - apply emphasis to extracted text + string defaultText = ExtractInlineText(inline); + paragraph.Append(defaultText, new Style(decoration: decoration)); + break; + } + } + } + + /// + /// Processes a link inline while applying emphasis decoration. + /// + 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 ?? ""; + } + + // 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! + ); + + paragraph.Append(linkText, linkStyle); + } + + /// + /// Processes inline code elements with syntax highlighting. + /// + private static void ProcessCodeInline(Paragraph paragraph, CodeInline code, Theme theme) + { + // Get theme colors for inline code + string[] codeScopes = new[] { "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. + /// + 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)) + { + 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 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 = new[] { "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); + } + + /// + /// Extracts plain text from inline elements without markup. + /// + private static string ExtractInlineText(Inline inline) + { + var builder = new System.Text.StringBuilder(); + ExtractInlineTextRecursive(inline, builder); + return builder.ToString(); + } + + /// + /// Represents a text segment that may or may not be a username link. + /// + 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) + { + var segmentList = new List(); + + // Simple regex to find @username patterns + var usernamePattern = new System.Text.RegularExpressions.Regex(@"@[a-zA-Z0-9_-]+"); + MatchCollection matches = usernamePattern.Matches(text); + + if (matches.Count == 0) + { + segments = []; + return false; + } + + int lastIndex = 0; + foreach (System.Text.RegularExpressions.Match match in matches) + { + // Add text before the username + if (match.Index > lastIndex) + { + segmentList.Add(new TextSegment(text[lastIndex..match.Index], false)); + } + + // Add the username + segmentList.Add(new TextSegment(match.Value, true)); + lastIndex = match.Index + match.Length; + } + + // Add remaining text + if (lastIndex < text.Length) + { + segmentList.Add(new TextSegment(text[lastIndex..], false)); + } + + segments = segmentList.ToArray(); + return true; + } + + private static void ExtractInlineTextRecursive(Inline inline, System.Text.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; + } + } +} diff --git a/src/Core/Markdown/Renderers/QuoteRenderer.cs b/src/Core/Markdown/Renderers/QuoteRenderer.cs new file mode 100644 index 0000000..c342587 --- /dev/null +++ b/src/Core/Markdown/Renderers/QuoteRenderer.cs @@ -0,0 +1,52 @@ +using System.Text; +using Markdig.Syntax; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; + +/// +/// Renders markdown quote blocks. +/// +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) + { + string quoteText = ExtractQuoteText(quote, theme); + + return new Panel(new Markup(Markup.Escape(quoteText))) + .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; + + foreach (Block subBlock in quote) + { + if (subBlock is ParagraphBlock para) + { + var quoteBuilder = new StringBuilder(); + InlineProcessor.ExtractInlineText(para.Inline, theme, quoteBuilder); + quoteText += quoteBuilder.ToString(); + } + else + { + quoteText += subBlock.ToString(); + } + } + + return quoteText; + } +} diff --git a/src/Core/Markdown/Renderers/TableRenderer.cs b/src/Core/Markdown/Renderers/TableRenderer.cs new file mode 100644 index 0000000..826770f --- /dev/null +++ b/src/Core/Markdown/Renderers/TableRenderer.cs @@ -0,0 +1,271 @@ +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +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 +{ + /// + /// Renders a markdown table by building Spectre.Console Table objects directly. + /// This approach provides proper theme color support and eliminates VT escaping issues. + /// + /// 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; + + // Configure table appearance + spectreTable.Border = TableBorder.Rounded; + spectreTable.BorderStyle = GetTableBorderStyle(theme); + + List<(bool isHeader, List cells)> allRows = ExtractTableDataOptimized(table, theme); + + if (allRows.Count == 0) + 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]; + // 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 + { + TableColumnAlign.Left => Justify.Left, + TableColumnAlign.Center => Justify.Center, + TableColumnAlign.Right => Justify.Right, + _ => Justify.Left + }; + } + spectreTable.AddColumn(column); + } + } + else + { + // No explicit headers, use first row as headers + (bool isHeader, List cells) firstRow = allRows.FirstOrDefault(); + if (firstRow.cells?.Count > 0) + { + for (int i = 0; i < firstRow.cells.Count; i++) + { + TableCellContent cell = firstRow.cells[i]; + var column = new TableColumn(cell.Text); + if (i < table.ColumnDefinitions.Count) + { + column.Alignment = table.ColumnDefinitions[i].Alignment switch + { + TableColumnAlign.Left => Justify.Left, + TableColumnAlign.Center => Justify.Center, + TableColumnAlign.Right => Justify.Right, + _ => Justify.Left + }; + } + spectreTable.AddColumn(column); + } + allRows = allRows.Skip(1).ToList(); + } + } + + // Add data rows + foreach ((bool isHeader, List? cells) in allRows.Where(r => !r.isHeader)) + { + if (cells?.Count > 0) + { + var rowCells = new List(); + foreach (TableCellContent? cell in cells) + { + Style cellStyle = GetCellStyle(theme); + rowCells.Add(new Text(cell.Text, cellStyle)); + } + spectreTable.AddRow(rowCells.ToArray()); + } + } + + return spectreTable; + } + + /// + /// Represents the content and styling of a table cell. + /// + private sealed record TableCellContent(string Text, TableColumnAlign? Alignment); + + /// + /// Extracts table data with optimized cell content processing. + /// + private static List<(bool isHeader, List cells)> ExtractTableDataOptimized( + Markdig.Extensions.Tables.Table table, Theme theme) + { + var result = new List<(bool isHeader, List cells)>(); + + foreach (Markdig.Extensions.Tables.TableRow row in table) + { + bool isHeader = row.IsHeader; + var cells = new List(); + + 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)); + } + } + + result.Add((isHeader, cells)); + } + + return result; + } + + /// + /// Extracts text from table cells using optimized inline processing. + /// + private static string ExtractCellTextOptimized(TableCell cell, Theme theme) + { + var textBuilder = new System.Text.StringBuilder(); + + 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) + { + textBuilder.Append(code.Lines.ToString()); + } + } + + return textBuilder.ToString().Trim(); + } + + /// + /// Extracts text from inline elements optimized for table cells. + /// + private static void ExtractInlineTextOptimized(ContainerInline inlines, System.Text.StringBuilder builder) + { + foreach (Inline inline in inlines) + { + switch (inline) + { + case LiteralInline literal: + builder.Append(literal.Content.ToString()); + break; + + case EmphasisInline emphasis: + // For tables, we extract just the text content + ExtractInlineTextRecursive(emphasis, builder); + break; + + case Markdig.Syntax.Inlines.CodeInline code: + builder.Append(code.Content); + break; + + case Markdig.Syntax.Inlines.LinkInline link: + ExtractInlineTextRecursive(link, builder); + break; + + default: + ExtractInlineTextRecursive(inline, builder); + break; + } + } + } + + /// + /// Recursively extracts text from inline elements. + /// + private static void ExtractInlineTextRecursive(Inline inline, System.Text.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 Markdig.Syntax.Inlines.LeafInline leaf: + if (leaf is Markdig.Syntax.Inlines.CodeInline code) + { + builder.Append(code.Content); + } + break; + } + } + + /// + /// Gets the border style for tables based on theme. + /// + 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) + { + return new Style(foreground: StyleHelper.GetColor(borderFg, theme)); + } + + return new Style(foreground: Color.Grey); + } + + /// + /// Gets the header style for table headers. + /// + 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); + } + + /// + /// Gets the cell style for table data cells. + /// + 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); + } +} diff --git a/src/Core/Markdown/Types/MarkdownTypes.cs b/src/Core/Markdown/Types/MarkdownTypes.cs new file mode 100644 index 0000000..f61c933 --- /dev/null +++ b/src/Core/Markdown/Types/MarkdownTypes.cs @@ -0,0 +1,139 @@ +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, + Heading, + Paragraph, + List, + FencedCodeBlock, + CodeBlock, + Table, + Quote, + HtmlBlock, + ThematicBreak, + 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/MarkdownLinkFormatter.cs b/src/Core/MarkdownLinkFormatter.cs new file mode 100644 index 0000000..937bce2 --- /dev/null +++ b/src/Core/MarkdownLinkFormatter.cs @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..be7655b --- /dev/null +++ b/src/Core/MarkdownRenderer.cs @@ -0,0 +1,131 @@ +using System.Text; +using PwshSpectreConsole.TextMate.Extensions; +using Spectre.Console; +using Spectre.Console.Rendering; +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. +/// +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 + /// 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); + (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); + } + } +} diff --git a/src/Core/MarkdownToken.cs b/src/Core/MarkdownToken.cs new file mode 100644 index 0000000..113569a --- /dev/null +++ b/src/Core/MarkdownToken.cs @@ -0,0 +1,20 @@ +using TextMateSharp.Grammars; + +namespace PwshSpectreConsole.TextMate.Core; + +/// +/// Simple token for theme lookup from a set of scopes (for markdown elements). +/// +internal sealed class MarkdownToken : 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]; + } +} diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs new file mode 100644 index 0000000..91b4cfc --- /dev/null +++ b/src/Core/StandardRenderer.cs @@ -0,0 +1,58 @@ +using System.Text; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +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 +{ + /// + /// Renders text lines using standard TextMate grammar processing. + /// Uses object pooling and batch processing for optimal performance. + /// + /// Lines to render + /// 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, Action? debugCallback) + { + var builder = new StringBuilder(); + 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(); + } + + return new Rows([.. rows]); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Argument error rendering content: {ex.Message}", ex); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unexpected error rendering content: {ex.Message}", ex); + } + } +} diff --git a/src/Core/StyleHelper.cs b/src/Core/StyleHelper.cs new file mode 100644 index 0000000..4aac03d --- /dev/null +++ b/src/Core/StyleHelper.cs @@ -0,0 +1,61 @@ +using Spectre.Console; +using TextMateSharp.Themes; + +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 +{ + /// + /// 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)); + } + + /// + /// Converts TextMate font style to Spectre Console decoration. + /// + /// TextMate font style + /// Spectre Console decoration + public static Decoration GetDecoration(FontStyle fontStyle) + { + Decoration result = Decoration.None; + if (fontStyle == FontStyle.NotSet) + return result; + if ((fontStyle & FontStyle.Italic) != 0) + result |= Decoration.Italic; + if ((fontStyle & FontStyle.Underline) != 0) + result |= Decoration.Underline; + if ((fontStyle & FontStyle.Bold) != 0) + result |= Decoration.Bold; + return result; + } + + /// + /// Converts a hex color string to a Spectre Console Color. + /// + /// Hex color string (with or without #) + /// Spectre Console Color instance + public static Color HexToColor(string hexString) + { + if (hexString.StartsWith('#')) + { + hexString = hexString[1..]; + } + + byte[] c = Convert.FromHexString(hexString); + return new Color(c[0], c[1], c[2]); + } +} diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs new file mode 100644 index 0000000..546d7c1 --- /dev/null +++ b/src/Core/TextMateProcessor.cs @@ -0,0 +1,147 @@ +using System.Text; +using PwshSpectreConsole.TextMate.Infrastructure; +using PwshSpectreConsole.TextMate.Extensions; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +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 +{ + /// + /// 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 lines array is null + /// Thrown when grammar cannot be found + /// Thrown when processing encounters an error + 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); + } + + 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()) + { + return null; + } + + try + { + (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); + IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); + + if (grammar is null) + { + string errorMessage = isExtension + ? $"Grammar not found for file extension: {grammarId}" + : $"Grammar not found for language: {grammarId}"; + throw new InvalidOperationException(errorMessage); + } + + // 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) + { + throw; + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Argument error processing lines with grammar '{grammarId}': {ex.Message}", ex); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unexpected error processing lines with grammar '{grammarId}': {ex.Message}", ex); + } + } + + /// + /// 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 + /// Rendered rows with syntax highlighting, or null if processing fails + public static Rows? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) + { + ArgumentNullException.ThrowIfNull(lines, nameof(lines)); + + try + { + (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); + IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); + + if (grammar is null) + { + string errorMessage = isExtension + ? $"Grammar not found for file extension: {grammarId}" + : $"Grammar not found for language: {grammarId}"; + throw new InvalidOperationException(errorMessage); + } + + // Always use StandardRenderer for code blocks, never MarkdownRenderer + return RenderCodeBlock(lines, theme, grammar); + } + catch (InvalidOperationException) + { + throw; + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Argument error processing code block with grammar '{grammarId}': {ex.Message}", ex); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unexpected error processing code block with grammar '{grammarId}': {ex.Message}", ex); + } + } + + /// + /// Renders code block lines without escaping markup characters. + /// + private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) + { + 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; + TokenProcessor.ProcessTokensBatchNoEscape(result.Tokens, line, theme, builder, null, lineIndex); + string lineMarkup = builder.ToString(); + rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); + builder.Clear(); + } + + return new Rows(rows.ToArray()); + } +} diff --git a/src/Core/TokenDebugInfo.cs b/src/Core/TokenDebugInfo.cs new file mode 100644 index 0000000..9b1cc4c --- /dev/null +++ b/src/Core/TokenDebugInfo.cs @@ -0,0 +1,19 @@ +using System.Collections.ObjectModel; +using Spectre.Console; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Core; + +public class TokenDebugInfo +{ + public int? LineIndex { get; set; } + public int StartIndex { get; set; } + public int EndIndex { get; set; } + public string? Text { get; set; } + public List? Scopes { get; set; } + public int Foreground { get; set; } + public int Background { get; set; } + public FontStyle FontStyle { get; set; } + public Style? Style { get; set; } + public ReadOnlyDictionary? Theme { get; set; } +} diff --git a/src/Core/TokenProcessor.cs b/src/Core/TokenProcessor.cs new file mode 100644 index 0000000..bf9ad1c --- /dev/null +++ b/src/Core/TokenProcessor.cs @@ -0,0 +1,148 @@ +using System.Text; +using Spectre.Console; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; +using PwshSpectreConsole.TextMate.Extensions; + +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 +{ + /// + /// Processes tokens in batches for better cache locality and performance. + /// + /// Tokens to process + /// Source line text + /// Theme for styling + /// StringBuilder for output + public static void ProcessTokensBatch( + 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); + (int foreground, int background, FontStyle fontStyle) = ExtractThemeProperties(token, theme); + (string escapedText, Style? style) = WriteTokenOptimized(textSpan, foreground, background, fontStyle, theme); + + builder.AppendWithStyle(style, escapedText); + + 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() + }); + } + } + + /// + /// 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); + (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); + + 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) + { + int foreground = -1; + int background = -1; + FontStyle fontStyle = FontStyle.NotSet; + + foreach (ThemeTrieElementRule? themeRule in theme.Match(token.Scopes)) + { + if (foreground == -1 && themeRule.foreground > 0) + foreground = themeRule.foreground; + if (background == -1 && themeRule.background > 0) + background = themeRule.background; + if (fontStyle == FontStyle.NotSet && themeRule.fontStyle > 0) + fontStyle = themeRule.fontStyle; + } + + return (foreground, background, fontStyle); + } + public static (string escapedText, Style? style) WriteTokenOptimized( + ReadOnlySpan text, + int foreground, + int background, + FontStyle fontStyle, + 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) + { + 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); + + return (processedText, style); + } + +} diff --git a/src/Core/Validation/MarkdownInputValidator.cs b/src/Core/Validation/MarkdownInputValidator.cs new file mode 100644 index 0000000..09a1060 --- /dev/null +++ b/src/Core/Validation/MarkdownInputValidator.cs @@ -0,0 +1,110 @@ +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) + { + return 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 + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) && + !Uri.TryCreate(url, UriKind.Relative, out uri)) + return null; + + return 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/Extensions/Extensions.cs b/src/Extensions/Extensions.cs deleted file mode 100644 index b2b95fc..0000000 --- a/src/Extensions/Extensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Text; -using System.Globalization; -using Spectre.Console; - - -namespace PwshSpectreConsole.TextMate; -internal static class StringExtensions -{ - internal static string SubstringAtIndexes(this string str, int startIndex, int endIndex) - { - return str[startIndex..endIndex]; - } -} - -internal static class StringBuilderExtensions -{ - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) - { - return AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); - } - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) - { - value ??= string.Empty; - if (style != null) - { - return builder.Append('[') - .Append(style.ToMarkup()) - .Append(']') - .Append(value.EscapeMarkup()) - .Append("[/]"); - } - return builder.Append(value); - } - public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? style, string? value) - { - value ??= string.Empty; - if (style != null) - { - return builder.Append('[') - .Append(style.ToMarkup()) - .Append(']') - .Append(value) - .Append("[/] "); - } - return builder.Append(value); - } - public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, ReadOnlySpan value) - { - if (style != null) - { - return builder.Append('[') - .Append(style.ToMarkup()) - .Append(']') - .Append(value) - .Append("[/]"); - } - return builder.Append(value); - } -} diff --git a/src/Extensions/SpanOptimizedStringExtensions.cs b/src/Extensions/SpanOptimizedStringExtensions.cs new file mode 100644 index 0000000..b6fa1c8 --- /dev/null +++ b/src/Extensions/SpanOptimizedStringExtensions.cs @@ -0,0 +1,186 @@ +using System.Text; + +namespace PwshSpectreConsole.TextMate.Extensions; + +/// +/// 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.ToArray(); + } + + /// + /// 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) + { + if (string.IsNullOrEmpty(source) || chars.IsEmpty) + return false; + + return 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/Extensions/StringBuilderExtensions.cs b/src/Extensions/StringBuilderExtensions.cs new file mode 100644 index 0000000..be023d7 --- /dev/null +++ b/src/Extensions/StringBuilderExtensions.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using System.Text; +using Spectre.Console; + +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 +{ + /// + /// Appends a Spectre.Console link markup: [link=url]text[/] + /// + /// StringBuilder to append to + /// 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) + { + builder.Append("[link=") + .Append(url.EscapeMarkup()) + .Append(']') + .Append(text.EscapeMarkup()) + .Append("[/]"); + return builder; + } + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) + { + return AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); + } + + 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 StringBuilder AppendWithStyleN(this StringBuilder builder, Style? style, string? value) + { + value ??= string.Empty; + if (style is not null) + { + return builder.Append('[') + .Append(style.ToMarkup()) + .Append(']') + .Append(value) + .Append("[/] "); + } + return builder.Append(value); + } + + /// + /// Efficiently appends text with optional style markup using spans to reduce allocations. + /// This method is optimized for the common pattern of conditional style application. + /// + /// StringBuilder to append to + /// 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('[') + .Append(style.ToMarkup()) + .Append(']') + .Append(value) + .Append("[/]"); + } + return builder.Append(value); + } +} diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs new file mode 100644 index 0000000..eef9374 --- /dev/null +++ b/src/Extensions/StringExtensions.cs @@ -0,0 +1,51 @@ +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. +/// +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. + /// + /// Source string to extract from + /// 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); + } + + /// + /// Optimized substring method that works with spans internally but returns a string. + /// Provides better performance than traditional substring while maintaining string return type. + /// + /// Source string to extract from + /// 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); + return span.IsEmpty ? string.Empty : span.ToString(); + } + + /// + /// Checks if all strings in the array are null or empty. + /// Uses modern pattern matching for cleaner, more efficient code. + /// + /// 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); + } +} diff --git a/src/Extensions/ThemeExtensions.cs b/src/Extensions/ThemeExtensions.cs new file mode 100644 index 0000000..de2a84d --- /dev/null +++ b/src/Extensions/ThemeExtensions.cs @@ -0,0 +1,57 @@ +using Spectre.Console; +using PwshSpectreConsole.TextMate.Core; +using TextMateSharp.Themes; + +namespace PwshSpectreConsole.TextMate.Extensions; +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); + } + /// + /// Converts a TextMate color to a Spectre.Console color. + /// + /// The TextMate color to convert. + /// 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 + { + return StyleHelper.HexToColor(hex); + } + catch + { + return Color.Default; + } + } + return Color.Default; + } + /// + /// Converts a TextMate font style to a Spectre.Console font style. + /// + /// The TextMate font style to convert. + /// A Spectre.Console font style representing the TextMate font style. + + public static FontStyle ToSpectreFontStyle(this FontStyle fontStyle) + { + FontStyle result = FontStyle.None; + if ((fontStyle & FontStyle.Italic) != 0) + result |= FontStyle.Italic; + if ((fontStyle & FontStyle.Bold) != 0) + result |= FontStyle.Bold; + if ((fontStyle & FontStyle.Underline) != 0) + result |= FontStyle.Underline; + return result; + } +} diff --git a/src/Helpers/Completers.cs b/src/Helpers/Completers.cs index 4777dd0..419f48a 100644 --- a/src/Helpers/Completers.cs +++ b/src/Helpers/Completers.cs @@ -1,7 +1,4 @@ - -using System; -using System.Linq; -using System.Management.Automation; +using System.Management.Automation; namespace PwshSpectreConsole.TextMate; public class TextMateLanguages : IValidateSetValuesGenerator @@ -23,11 +20,12 @@ public string[] GetValidValues() } public static bool IsSupportedExtension(string extension) { - return TextMateHelper.Extensions.Contains(extension); + return TextMateHelper.Extensions?.Contains(extension) == true; } public static bool IsSupportedFile(string file) { - return TextMateHelper.Extensions.Contains(System.IO.Path.GetExtension(file)); + string ext = Path.GetExtension(file); + return TextMateHelper.Extensions?.Contains(ext) == true; } } @@ -39,7 +37,7 @@ public override object Transform(EngineIntrinsics engineIntrinsics, object input { return input.StartsWith('.') ? input : '.' + input; } - return inputData; + 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 b4a8698..3ab0553 100644 --- a/src/Helpers/Debug.cs +++ b/src/Helpers/Debug.cs @@ -1,10 +1,7 @@  -using System; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using TextMateSharp.Registry; using Spectre.Console; -using System.Collections.Generic; using System.Collections.ObjectModel; // this is just for debugging purposes. @@ -15,126 +12,53 @@ public static class Test { public class TextMateDebug { - public int[]? Index { get; set; } + public int? LineIndex { get; set; } + public int StartIndex { get; set; } + public int EndIndex { get; set; } public string? Text { get; set; } public List? Scopes { get; set; } - public int[]? color { get; set; } + public int Foreground { get; set; } + public int Background { get; set; } public FontStyle FontStyle { get; set; } + public Style? Style { get; set; } public ReadOnlyDictionary? Theme { get; set; } } - public class TokenDebug - { - public string? text { get; set; } - public Style? style { get; set; } - } public static TextMateDebug[]? DebugTextMate(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) { - RegistryOptions options = new(themeName); - Registry registry = new(options); - Theme theme = registry.GetTheme(); - IGrammar grammar = null!; - if (FromFile) - { - grammar = registry.LoadGrammar(options.GetScopeByExtension(grammarId)); - } - else { - grammar = registry.LoadGrammar(options.GetScopeByLanguageId(grammarId)); - } - IStateStack? ruleStack = null; - List debugList = new(); - int lineIndex = 0; - foreach (string line in lines) - { - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - for (int i = 0; i < result.Tokens.Length; i++) + var debugList = new List(); + PwshSpectreConsole.TextMate.Core.TextMateProcessor.ProcessLines( + lines, + themeName, + grammarId, + isExtension: FromFile, + debugCallback: info => debugList.Add(new TextMateDebug { - IToken token = result.Tokens[i]; - int startIndex = (token.StartIndex > line.Length) ? - line.Length : token.StartIndex; - int endIndex = (token.EndIndex > line.Length) ? - line.Length : token.EndIndex; - int foreground = -1; - int background = -1; - FontStyle fontStyle = FontStyle.NotSet; - foreach (var themeRule in theme.Match(token.Scopes)) - { - if (foreground == -1 && themeRule.foreground > 0) - foreground = themeRule.foreground; - if (background == -1 && themeRule.background > 0) - background = themeRule.background; - if (fontStyle == FontStyle.NotSet && themeRule.fontStyle > 0) - fontStyle = themeRule.fontStyle; - } - ReadOnlyDictionary? colorDictionary = theme.GetGuiColorDictionary(); - int[] index = { lineIndex, startIndex, endIndex }; - int[] color = { foreground, background }; - TextMateDebug textMateDebug = new() { - Index = index, - Text = line.SubstringAtIndexes(startIndex, endIndex), - Scopes = token.Scopes, - color = color, - FontStyle = fontStyle, - Theme = colorDictionary - }; - debugList.Add(textMateDebug); - } - lineIndex++; - } + 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.ToArray(); } - public static TokenDebug[]? DebugTextMateTokens(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) - { - RegistryOptions options = new(themeName); - Registry registry = new(options); - Theme theme = registry.GetTheme(); - IGrammar grammar = null!; - if (FromFile) - { - grammar = registry.LoadGrammar(options.GetScopeByExtension(grammarId)); - } - else - { - grammar = registry.LoadGrammar(options.GetScopeByLanguageId(grammarId)); - } - IStateStack? ruleStack = null; - List tokenDebug = new(); - - foreach (string line in lines) - { - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - for (int i = 0; i < result.Tokens.Length; i++) - { - IToken token = result.Tokens[i]; - int startIndex = (token.StartIndex > line.Length) ? - line.Length : token.StartIndex; - int endIndex = (token.EndIndex > line.Length) ? - line.Length : token.EndIndex; - int foreground = -1; - int background = -1; - FontStyle fontStyle = FontStyle.NotSet; - foreach (var themeRule in theme.Match(token.Scopes)) - { - if (foreground == -1 && themeRule.foreground > 0) - foreground = themeRule.foreground; - - if (background == -1 && themeRule.background > 0) - background = themeRule.background; - - if (fontStyle == FontStyle.NotSet && themeRule.fontStyle > 0) - fontStyle = themeRule.fontStyle; - } - var (textEscaped, style) = Converter.WriteToken(line.SubstringAtIndexes(startIndex, endIndex), foreground, background, fontStyle, theme); - TokenDebug tokenDebugItem = new() { - text = textEscaped, - style = style - }; - tokenDebug.Add(tokenDebugItem); - } - } - return tokenDebug.ToArray(); + public static Core.TokenDebugInfo[]? DebugTextMateTokens(string[] lines, ThemeName themeName, string grammarId, bool FromFile = false) + { + var debugList = new List(); + PwshSpectreConsole.TextMate.Core.TextMateProcessor.ProcessLines( + lines, + themeName, + grammarId, + isExtension: FromFile, + debugCallback: info => debugList.Add(info) + ); + return debugList.ToArray(); } } diff --git a/src/Helpers/Helpers.cs b/src/Helpers/Helpers.cs index d91f2fc..9b4c51f 100644 --- a/src/Helpers/Helpers.cs +++ b/src/Helpers/Helpers.cs @@ -1,35 +1,31 @@ - -using System; -using System.Collections.Generic; -using System.Linq; -using TextMateSharp.Grammars; +using TextMateSharp.Grammars; namespace PwshSpectreConsole.TextMate; -internal static class TextMateHelper +public static class TextMateHelper { - internal static readonly RegistryOptions _registryOptions = new(ThemeName.Dark); - internal static List AvailableLanguages = _registryOptions.GetAvailableLanguages(); - internal static readonly string[] Extensions; - internal static readonly string[] Languages; - - static TextMateHelper() - { - try + public static readonly string[] Extensions; + public static readonly string[] Languages; + public static readonly List AvailableLanguages; + static TextMateHelper() { - Extensions = AvailableLanguages - .Where(x => x.Extensions != null) - .SelectMany(x => x.Extensions) - .ToArray(); + try + { + RegistryOptions _registryOptions = new(ThemeName.DarkPlus); + AvailableLanguages = _registryOptions.GetAvailableLanguages(); - Languages = AvailableLanguages - .Where(x => x.Id != null) - .Select(x => x.Id) - .ToArray(); - } - catch (Exception ex) - { - throw new TypeInitializationException(nameof(TextMateHelper), ex); + // Get all the extensions and languages from the available languages + Extensions = [.. AvailableLanguages + .Where(x => x.Extensions is not null) + .SelectMany(x => x.Extensions)]; + + Languages = [.. AvailableLanguages + .Where(x => x.Id is not null) + .Select(x => x.Id)]; + } + catch (Exception ex) + { + throw new TypeInitializationException(nameof(TextMateHelper), ex); + } } - } } diff --git a/src/Helpers/ImageFile.cs b/src/Helpers/ImageFile.cs new file mode 100644 index 0000000..a7755f0 --- /dev/null +++ b/src/Helpers/ImageFile.cs @@ -0,0 +1,213 @@ +// 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; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace PwshSpectreConsole.TextMate.Core.Helpers; + +/// +/// Normalizes various image sources (file paths, URLs, base64) into file paths that can be used by Spectre.Console.SixelImage. +/// +internal static 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); + + /// + /// 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)) + { + return null; + } + + // Check if it's a base64 data URI + 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")) + { + return await DownloadImageToTempFileAsync(uri); + } + + // Check if it's a local file path + 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; + } + + /// + /// Converts a base64 encoded image to a temporary file. + /// + /// 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 + { + 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)) + { + File.Delete(tempFileName); + } + } + catch + { + // Ignore cleanup errors + } + }); + + return tempFileName; + } + catch + { + return null; + } + } + + /// + /// Downloads an image from a URL to a temporary file. + /// + /// The image URL + /// Path to the temporary file, or null if download fails + private static async Task DownloadImageToTempFileAsync(Uri imageUri) + { + try + { + using HttpResponseMessage response = await HttpClient.GetAsync(imageUri); + if (!response.IsSuccessStatusCode) + { + return null; + } + + string? contentType = response.Content.Headers.ContentType?.MediaType; + string extension = GetExtensionFromContentType(contentType) ?? + Path.GetExtension(imageUri.LocalPath) ?? + ".img"; + + string tempFileName = Path.Combine(Path.GetTempPath(), $"pstextmate_img_{Guid.NewGuid():N}{extension}"); + + using FileStream fileStream = File.Create(tempFileName); + await response.Content.CopyToAsync(fileStream); + + // Schedule cleanup after a reasonable time (1 hour) + _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => + { + try + { + if (File.Exists(tempFileName)) + { + File.Delete(tempFileName); + } + } + catch + { + // Ignore cleanup errors + } + }); + + return tempFileName; + } + catch + { + return null; + } + } + + /// + /// Gets the file extension based on the content type. + /// + /// The MIME content type + /// The appropriate file extension + private static string? GetExtensionFromContentType(string? contentType) + { + return contentType?.ToLowerInvariant() switch + { + "image/jpeg" => ".jpg", + "image/jpg" => ".jpg", + "image/png" => ".png", + "image/gif" => ".gif", + "image/bmp" => ".bmp", + "image/webp" => ".webp", + "image/svg+xml" => ".svg", + "image/tiff" => ".tif", + _ => null + }; + } + + /// + /// Checks if the image source is likely to be supported by SixelImage. + /// + /// The image source to check + /// True if the image source is likely supported + 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" }; + + if (supportedExtensions.Contains(extension)) + { + return true; + } + + // Check for base64 data URI with supported format + Match base64Match = Base64Regex.Match(imageSource); + if (base64Match.Success) + { + string imageType = base64Match.Groups["type"].Value.ToLowerInvariant(); + string[] supportedTypes = new[] { "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")) + { + string urlExtension = Path.GetExtension(uri.LocalPath).ToLowerInvariant(); + return supportedExtensions.Contains(urlExtension); + } + + return false; + } +} diff --git a/src/Helpers/VTConversion.cs b/src/Helpers/VTConversion.cs new file mode 100644 index 0000000..863ace8 --- /dev/null +++ b/src/Helpers/VTConversion.cs @@ -0,0 +1,616 @@ +using System.Runtime.CompilerServices; +using Spectre.Console; + +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 +{ + private const char ESC = '\x1B'; + 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. + /// This is more efficient than ToMarkup() as it directly constructs the Paragraph + /// without intermediate markup string generation and parsing. + /// + /// Input string with VT escape sequences + /// Paragraph object with parsed styles applied + 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; + int i = 0; + + 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); + segments.Add(new TextSegment(text, currentStyle.Clone())); + } + + // Parse CSI escape sequence + int escapeEnd = ParseEscapeSequence(span, i, ref currentStyle); + if (escapeEnd > i) + { + i = escapeEnd; + textStart = i; + } + else + { + i++; + } + } + else if (span[i + 1] == OSC_START) + { + // Add text segment before OSC sequence + if (i > textStart) + { + string text = input.Substring(textStart, i - textStart); + segments.Add(new TextSegment(text, currentStyle.Clone())); + } + + // Parse OSC sequence + OscResult oscResult = ParseOscSequence(span, i, ref currentStyle); + 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())); + } + i = oscResult.End; + textStart = i; + } + else + { + i++; + } + } + else + { + i++; + } + } + else + { + i++; + } + } + + // Add remaining text + if (textStart < span.Length) + { + string text = input.Substring(textStart); + segments.Add(new TextSegment(text, currentStyle.Clone())); + } + + return segments; + } + + /// + /// 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) + { + 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'); + hasNumber = true; + } + else if (span[i] == ';') + { + parameters.Add(hasNumber ? currentNumber : 0); + currentNumber = 0; + hasNumber = false; + } + else + { + // Invalid character, abort parsing + return start + 1; + } + i++; + } + + if (i >= span.Length || span[i] != SGR_END) + { + return start + 1; // Invalid sequence + } + + // Add the last parameter + parameters.Add(hasNumber ? currentNumber : 0); + + // Apply SGR parameters to style + ApplySgrParameters(parameters, ref style); + + return i + 1; // Return position after 'm' + } + + /// + /// 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; + } + } + + /// + /// 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) + { + 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] == ';') + { + 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] != ';') + { + 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] == '\\') + { + urlEnd = i; + break; + } + i++; + } + + if (urlEnd > urlStart) + { + string url = span.Slice(urlStart, urlEnd - urlStart).ToString(); + i = urlEnd + 2; // Skip ESC\ + + // Check if this is a link start (has URL) or link end (empty) + if (!string.IsNullOrEmpty(url)) + { + // This is a link start - find the link text and end sequence + int linkTextStart = i; + 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\ + { + 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] == '\\') + { + linkTextEnd = i; + break; + } + i++; + } + + if (linkTextEnd > linkTextStart) + { + string linkText = span.Slice(linkTextStart, linkTextEnd - linkTextStart).ToString(); + style.Link = url; + return new OscResult(linkTextEnd + 7, linkText); // Skip ESC]8;;ESC\ + } + } + else + { + // This is likely a link end sequence: ESC]8;;ESC\ + style.Link = null; + return new OscResult(i); + } + } + } + } + + // 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] == '\\') + { + return new OscResult(i + 2); + } + i++; + } + + return new OscResult(start + 1); // Failed to parse, advance by 1 + } + + /// + /// 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++) + { + int param = parameters[i]; + + switch (param) + { + case 0: // Reset + style.Reset(); + break; + case 1: // Bold + style.Decoration |= Decoration.Bold; + break; + case 2: // Dim + style.Decoration |= Decoration.Dim; + break; + case 3: // Italic + style.Decoration |= Decoration.Italic; + break; + case 4: // Underline + style.Decoration |= Decoration.Underline; + break; + case 5: // Slow blink + style.Decoration |= Decoration.SlowBlink; + break; + case 6: // Rapid blink + style.Decoration |= Decoration.RapidBlink; + break; + case 7: // Reverse video + style.Decoration |= Decoration.Invert; + break; + case 8: // Conceal + style.Decoration |= Decoration.Conceal; + break; + case 9: // Strikethrough + style.Decoration |= Decoration.Strikethrough; + break; + case 22: // Normal intensity (not bold or dim) + style.Decoration &= ~(Decoration.Bold | Decoration.Dim); + break; + case 23: // Not italic + style.Decoration &= ~Decoration.Italic; + break; + case 24: // Not underlined + style.Decoration &= ~Decoration.Underline; + break; + case 25: // Not blinking + style.Decoration &= ~(Decoration.SlowBlink | Decoration.RapidBlink); + break; + case 27: // Not reversed + style.Decoration &= ~Decoration.Invert; + break; + case 28: // Not concealed + style.Decoration &= ~Decoration.Conceal; + break; + case 29: // Not strikethrough + style.Decoration &= ~Decoration.Strikethrough; + break; + case >= 30 and <= 37: // 3-bit foreground colors + style.Foreground = GetConsoleColor(param); + break; + case 38: // Extended foreground color + if (i + 1 < parameters.Count) + { + int colorType = parameters[i + 1]; + if (colorType == 2 && i + 4 < parameters.Count) // 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.Count) // 256-color + { + int colorIndex = parameters[i + 2]; + style.Foreground = Get256Color(colorIndex); + i += 2; + } + } + break; + case 39: // Default foreground color + style.Foreground = null; + break; + case >= 40 and <= 47: // 3-bit background colors + style.Background = GetConsoleColor(param); + break; + case 48: // Extended background color + if (i + 1 < parameters.Count) + { + int colorType = parameters[i + 1]; + if (colorType == 2 && i + 4 < parameters.Count) // 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.Background = new Color(r, g, b); + i += 4; + } + else if (colorType == 5 && i + 2 < parameters.Count) // 256-color + { + int colorIndex = parameters[i + 2]; + style.Background = Get256Color(colorIndex); + i += 2; + } + } + break; + case 49: // Default background color + style.Background = null; + break; + 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 + style.Background = GetConsoleColor(param); + break; + } + } + } + + /// + /// 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.Red, + // 32 or 42 => Color.Green, + // 33 or 43 => Color.Yellow, + // 34 or 44 => Color.Blue, + // 35 or 45 => Color.Purple, + // 36 or 46 => Color.Teal, + // 37 or 47 => Color.White, + // 90 or 100 => Color.Grey, + // 91 or 101 => Color.Red1, + // 92 or 102 => Color.Green1, + // 93 or 103 => Color.Yellow1, + // 94 or 104 => Color.Blue1, + // 95 or 105 => Color.Fuchsia, + // 96 or 106 => Color.Aqua, + // 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 + }; + + /// + /// Gets a Color object for 256-color palette. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Color Get256Color(int index) + { + if (index < 0 || index > 255) + return Color.Default; + + // Standard 16 colors + if (index < 16) + { + return index switch + { + 0 => Color.Black, + 1 => Color.Maroon, + 2 => Color.Green, + 3 => Color.Olive, + 4 => Color.Navy, + 5 => Color.Purple, + 6 => Color.Teal, + 7 => Color.Silver, + 8 => Color.Grey, + 9 => Color.Red, + 10 => Color.Lime, + 11 => Color.Yellow, + 12 => Color.Blue, + 13 => Color.Fuchsia, + 14 => Color.Aqua, + 15 => Color.White, + _ => Color.Default + }; + } + + // 216 color cube (16-231) + 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); + return new Color(r, g, b); + } + + // Grayscale (232-255) + byte gray = (byte)((index - 232) * 10 + 8); + return new Color(gray, gray, gray); + } + + /// + /// Checks if a character is a digit. + /// + [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 + { + 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; + } + } + + /// + /// Represents the current style state during parsing. + /// + private struct StyleState + { + public Color? Foreground; + public Color? Background; + public Decoration Decoration; + public string? Link; + + public readonly bool HasAnyStyle => Foreground.HasValue || Background.HasValue || Decoration != Decoration.None || !string.IsNullOrEmpty(Link); + + public void Reset() + { + Foreground = null; + Background = null; + Decoration = Decoration.None; + Link = null; + } + + 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 string ToMarkup() + { + var parts = new List(); + + if (Foreground.HasValue) + { + parts.Add(Foreground.Value.ToMarkup()); + } + else + { + parts.Add("Default "); + + } + + if (Background.HasValue) + parts.Add($"on {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 (!string.IsNullOrEmpty(Link)) + { + parts.Add($"link={Link}"); + } + + return string.Join(" ", parts); + } + } +} diff --git a/src/Infrastructure/CacheManager.cs b/src/Infrastructure/CacheManager.cs new file mode 100644 index 0000000..c27b9cf --- /dev/null +++ b/src/Infrastructure/CacheManager.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using TextMateSharp.Grammars; +using TextMateSharp.Registry; +using TextMateSharp.Themes; + +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 +{ + private static readonly ConcurrentDictionary _themeCache = new(); + private static readonly ConcurrentDictionary _grammarCache = new(); + + /// + /// Gets or creates a cached theme and registry combination. + /// Avoids expensive reconstruction of theme objects on each operation. + /// + /// The theme to load + /// Cached registry and theme pair + 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(); + return (registry, theme); + }); + } + + /// + /// Gets or creates a cached grammar for the specified language or extension. + /// Grammars are expensive to load and parse, so caching provides significant performance benefits. + /// + /// Registry to load grammar from + /// 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) + { + string cacheKey = $"{grammarId}_{isExtension}"; + return _grammarCache.GetOrAdd(cacheKey, _ => + { + RegistryOptions options = new(ThemeName.Dark); // Use default for grammar loading + return isExtension + ? registry.LoadGrammar(options.GetScopeByExtension(grammarId)) + : registry.LoadGrammar(options.GetScopeByLanguageId(grammarId)); + }); + } + + /// + /// Clears all cached objects. Useful for memory management or when themes/grammars change. + /// + public static void ClearCache() + { + _themeCache.Clear(); + _grammarCache.Clear(); + } +} diff --git a/src/PSTextMate.csproj b/src/PSTextMate.csproj index dfd4b0d..c880cde 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -3,23 +3,27 @@ PSTextMate net8.0 enable - false - Latest + 0.1.0 + enable + 13.0 + Recommended + true - - - + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/TextMate/PSTextMate.cs b/src/TextMate/PSTextMate.cs deleted file mode 100644 index d04059f..0000000 --- a/src/TextMate/PSTextMate.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Collections.Generic; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; -using TextMateSharp.Registry; -using Spectre.Console; -using Spectre.Console.Rendering; - -namespace PwshSpectreConsole.TextMate; -public static class Converter -{ - public static Rows? String(string[] lines, ThemeName themeName, string grammarId) - { - RegistryOptions options = new(themeName); - Registry registry = new(options); - Theme theme = registry.GetTheme(); - IGrammar grammar = registry.LoadGrammar(options.GetScopeByLanguageId(grammarId)); - if (grammar == null) - { - throw new Exception("Grammar not found for language: " + grammarId); - } - if (grammar.GetName() == "Markdown") - { - return RenderMarkdown(lines, theme, grammar); - } - return Render(lines, theme, grammar); - } - - public static Rows? ReadFile(string fullName, ThemeName themeName, string Extension) - { - string[] lines = File.ReadAllLines(fullName); - RegistryOptions options = new(themeName); - Registry registry = new(options); - Theme theme = registry.GetTheme(); - IGrammar grammar = registry.LoadGrammar(options.GetScopeByExtension(Extension)); - if (grammar == null) - { - throw new Exception("Grammar not found for extension: " + Extension); - } - if (grammar.GetName() == "Markdown") - { - return RenderMarkdown(lines, theme, grammar); - } - return Render(lines, theme, grammar); - } - // specialcase markdown for spectre link rendering.. maybe more in the future.. - // prefer to do this with TextMate grammar, need to check if that is possible. - internal static Rows? RenderMarkdown(string[] String, Theme theme, IGrammar grammar) - { - StringBuilder builder = new(); - List rows = new(); - string? url = null!; - string? title = null!; - try - { - IStateStack? ruleStack = null; - foreach (string line in String) - { - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - for (int i = 0; i < result.Tokens.Length; i++) - { - IToken token = result.Tokens[i]; - if (token.Scopes.Contains("meta.link.inline.markdown")) - { - i++; // first token should just be a bracket - while (i < result.Tokens.Length && result.Tokens[i].Scopes.Contains("meta.link.inline.markdown")) - { - // while loop is a bit hacky, but if someone has multiple links back to back.. it should work. - if (result.Tokens[i].Scopes.Contains("string.other.link.title.markdown")) - { - title = line.SubstringAtIndexes(result.Tokens[i].StartIndex, result.Tokens[i].EndIndex); - } - if (result.Tokens[i].Scopes.Contains("markup.underline.link.markdown")) - { - url = line.SubstringAtIndexes(result.Tokens[i].StartIndex, result.Tokens[i].EndIndex); - } - if (title != null && url != null) - { - // (string _text, Style _style) = WriteMarkdownLinkWStyle(url, title); - // builder.AppendWithStyleN(_style, _text); - builder.Append(WriteMarkdownLink(url, title)); - title = null; - url = null; - } - i++; - } - continue; - } - int startIndex = (token.StartIndex > line.Length) ? line.Length : token.StartIndex; - int endIndex = (token.EndIndex > line.Length) ? line.Length : token.EndIndex; - int foreground = -1; - int background = -1; - FontStyle fontStyle = FontStyle.NotSet; - foreach (var themeRule in theme.Match(token.Scopes)) - { - if (foreground == -1 && themeRule.foreground > 0) - foreground = themeRule.foreground; - if (background == -1 && themeRule.background > 0) - background = themeRule.background; - if (fontStyle == FontStyle.NotSet && themeRule.fontStyle > 0) - fontStyle = themeRule.fontStyle; - } - var (textEscaped, style) = WriteToken(line.SubstringAtIndexes(startIndex, endIndex), foreground, background, fontStyle, theme); - builder.AppendWithStyle(style, textEscaped); - } - var lineMarkup = builder.ToString(); - // Preserve empty lines in rows output by using Text.Empty, Markup is stripping them for some reason - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); - builder.Clear(); - } - return new Rows(rows.ToArray()); - } - catch (Exception ex) - { - throw new Exception("ERROR: " + ex.Message); - } - } - - internal static Rows? Render(string[] String, Theme theme, IGrammar grammar) - { - StringBuilder builder = new(); - List rows = new(); - try - { - IStateStack? ruleStack = null; - foreach (string line in String) - { - ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); - ruleStack = result.RuleStack; - foreach (IToken token in result.Tokens) - { - int startIndex = (token.StartIndex > line.Length) ? line.Length : token.StartIndex; - int endIndex = (token.EndIndex > line.Length) ? line.Length : token.EndIndex; - int foreground = -1; - int background = -1; - FontStyle fontStyle = FontStyle.NotSet; - foreach (var themeRule in theme.Match(token.Scopes)) - { - if (foreground == -1 && themeRule.foreground > 0) - foreground = themeRule.foreground; - if (background == -1 && themeRule.background > 0) - background = themeRule.background; - if (fontStyle == FontStyle.NotSet && themeRule.fontStyle > 0) - fontStyle = themeRule.fontStyle; - } - var (textEscaped, style) = WriteToken(line.SubstringAtIndexes(startIndex, endIndex), foreground, background, fontStyle, theme); - builder.AppendWithStyle(style, textEscaped); - } - var lineMarkup = builder.ToString(); - // Preserve empty lines in rows output by using Text.Empty, Markup is stripping them for some reason - rows.Add(string.IsNullOrEmpty(lineMarkup) ? Text.Empty : new Markup(lineMarkup)); - builder.Clear(); - } - return new Rows(rows.ToArray()); - } - catch (Exception ex) - { - throw new Exception("ERROR: " + ex.Message); - } - } - - internal static (string textEscaped, Style? style) WriteToken(string text, int foreground, int background, FontStyle fontStyle, Theme theme) - { - string textEscaped = Markup.Escape(text); - if (foreground == -1 && background == -1 && fontStyle == FontStyle.NotSet) - { - return (textEscaped, null); - } - Decoration decoration = GetDecoration(fontStyle); - Color backgroundColor = GetColor(background, theme); - Color foregroundColor = GetColor(foreground, theme); - Style style = new(foregroundColor, backgroundColor, decoration); - return (textEscaped, style); - } - internal static string WriteMarkdownLink(string url, string linkText) - { - // string EscapedText = Markup.Escape(linkText); - return $"[Blue link={url}]{linkText}[/] "; - } - internal static (string textEscaped, Style style) WriteMarkdownLinkWStyle(string url, string linkText) - { - string mdlink = $"[link={url}]{Markup.Escape(linkText)}[/]"; - Style style = new(Color.Blue, Color.Default); - return (mdlink, style); - } - - internal static Color GetColor(int colorId, Theme theme) - { - if (colorId == -1) - { - return Color.Default; - } - return HexToColor(theme.GetColor(colorId)); - } - - internal static Decoration GetDecoration(FontStyle fontStyle) - { - Decoration result = Decoration.None; - if (fontStyle == FontStyle.NotSet) - return result; - if ((fontStyle & FontStyle.Italic) != 0) - result |= Decoration.Italic; - if ((fontStyle & FontStyle.Underline) != 0) - result |= Decoration.Underline; - if ((fontStyle & FontStyle.Bold) != 0) - result |= Decoration.Bold; - return result; - } - - internal static Color HexToColor(string hexString) - { - if (hexString.StartsWith("#")) - { - hexString = hexString[1..]; - } - - var c = Convert.FromHexString(hexString); - return new Color(c[0], c[1], c[2]); - } - internal static bool AllIsNullOrEmpty(string[] strings) - { - if (strings == null) - { - return true; - } - - foreach (string s in strings) - { - if (!string.IsNullOrEmpty(s)) - { - return false; - } - } - return true; - } - -} diff --git a/tests/Core/Markdown/MarkdownRendererTests.cs b/tests/Core/Markdown/MarkdownRendererTests.cs new file mode 100644 index 0000000..8ada35c --- /dev/null +++ b/tests/Core/Markdown/MarkdownRendererTests.cs @@ -0,0 +1,82 @@ +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 = MarkdownRenderer.Render(markdown, theme, themeName); + + // Assert + result.Should().NotBeNull(); + result.Renderables.Should().NotBeEmpty(); + } + + [Fact] + public void Render_EmptyMarkdown_ReturnsEmptyRows() + { + // Arrange + var markdown = ""; + var theme = CreateTestTheme(); + var themeName = ThemeName.DarkPlus; + + // Act + var result = MarkdownRenderer.Render(markdown, theme, themeName); + + // Assert + result.Should().NotBeNull(); + result.Renderables.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 = MarkdownRenderer.Render(markdown, theme, themeName); + + // Assert + result.Should().NotBeNull(); + result.Renderables.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 = MarkdownRenderer.Render(markdownHeading, theme, themeName); + + // Assert + result.Should().NotBeNull(); + result.Renderables.Should().HaveCount(1); + } + + private static Theme CreateTestTheme() + { + // Create a minimal theme for testing + var registryOptions = new RegistryOptions(ThemeName.DarkPlus); + var registry = new Registry(registryOptions); + return registry.GetTheme(); + } +} diff --git a/tests/Extensions/StringBuilderExtensionsTests.cs b/tests/Extensions/StringBuilderExtensionsTests.cs new file mode 100644 index 0000000..51056c1 --- /dev/null +++ b/tests/Extensions/StringBuilderExtensionsTests.cs @@ -0,0 +1,91 @@ +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/tests/GlobalUsings.cs b/tests/GlobalUsings.cs new file mode 100644 index 0000000..f73575b --- /dev/null +++ b/tests/GlobalUsings.cs @@ -0,0 +1,8 @@ +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/tests/Integration/TaskListIntegrationTests.cs b/tests/Integration/TaskListIntegrationTests.cs new file mode 100644 index 0000000..1fd1866 --- /dev/null +++ b/tests/Integration/TaskListIntegrationTests.cs @@ -0,0 +1,103 @@ +using PwshSpectreConsole.TextMate.Core; +using TextMateSharp.Grammars; + +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 MarkdigSpectreMarkdownRenderer_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 = MarkdigSpectreMarkdownRenderer.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 Renderables collection is not empty + result.Renderables.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 MarkdigSpectreMarkdownRenderer_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); + + result.Should().NotBeNull(); + result.Renderables.Should().NotBeEmpty(); + } + + [Fact] + public void MarkdigSpectreMarkdownRenderer_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 = MarkdigSpectreMarkdownRenderer.Render(markdown, theme, themeName); + + result.Should().NotBeNull(); + result.Renderables.Should().NotBeEmpty(); + + // Verify we have multiple rendered elements (headings, lists, etc.) + result.Renderables.Should().HaveCountGreaterThan(3); + } + + private static TextMateSharp.Themes.Theme CreateTestTheme() + { + var registryOptions = new TextMateSharp.Registry.RegistryOptions(ThemeName.DarkPlus); + var registry = new TextMateSharp.Registry.Registry(registryOptions); + return registry.GetTheme(); + } +} diff --git a/tests/Integration/TaskListReflectionRemovalTests.cs b/tests/Integration/TaskListReflectionRemovalTests.cs new file mode 100644 index 0000000..b97df85 --- /dev/null +++ b/tests/Integration/TaskListReflectionRemovalTests.cs @@ -0,0 +1,101 @@ +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/tests/PSTextMate.Tests.csproj b/tests/PSTextMate.Tests.csproj new file mode 100644 index 0000000..d1e3e10 --- /dev/null +++ b/tests/PSTextMate.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/test-markdown-rendering.md b/tests/test-markdown-rendering.md new file mode 100644 index 0000000..9d4ab70 --- /dev/null +++ b/tests/test-markdown-rendering.md @@ -0,0 +1,97 @@ +# Markdown Rendering Test File + +## Code Blocks Test + +### Fenced Code Block with Language + +```csharp +public class TestClass +{ + public string Name { get; set; } = "Test"; + + public void DoSomething() + { + Console.WriteLine($"Hello {Name}!"); + } +} +``` + +### PowerShell Code Block + +```powershell +Get-Process | Where-Object { $_.ProcessName -like "pwsh*" } | Select-Object ProcessName, Id +``` + +### Plain Code Block + +``` +This is a plain code block +with no language specified +and multiple lines +``` + +## Task Lists + +- [x] Completed task with checkmark +- [ ] Incomplete task with empty checkbox +- [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. + +## Tables + +| Column 1 | Column 2 | Column 3 | +|----------|----------|----------| +| Row 1 A | Row 1 B | Row 1 C | +| Row 2 A | Row 2 B | Row 2 C | + +## Mixed Content + +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 + +## Special Characters and VT Sequences + +Text with potential VT sequences: `\x1b[31mRed Text\x1b[0m` + +## Edge Cases + +### Empty Code Block + +``` + +``` + +### Code Block with Trailing Whitespace + +```javascript +function test() { + console.log("test"); +} +``` + +### Nested Lists with Tasks + +1. First item + - [x] Nested completed task + - [ ] Nested incomplete task +2. Second item + - [ ] Another nested task diff --git a/tests/test-markdown.md b/tests/test-markdown.md new file mode 100644 index 0000000..0a87b1a --- /dev/null +++ b/tests/test-markdown.md @@ -0,0 +1,136 @@ +# Markdown Test File + +This file is for testing all supported markdown features in PSTextMate. + +--- + +## Headings + +# Heading 1 + +## Heading 2 + +### Heading 3 + +--- + +## Paragraphs and Line Breaks + +This is a paragraph with a line break. +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 + +1. Ordered item 1 +2. Ordered item 2 + 1. Nested ordered item +3. Ordered item 3 + +- [x] Completed task +- [ ] Incomplete task + +--- + +## Blockquote + +> This is a blockquote. +> It can span multiple lines. + +--- + +## Code + +Inline code: `Write-Host "Hello, World!"` + +``` +This is a fenced code block with no language. +``` + +```powershell +# PowerShell code block +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); +} +``` + +--- + +## Table + +| Name | Value | +|---------|-------| +| Alpha | 1 | +| Beta | 2 | +| Gamma | 3 | + +--- + +## Images + +![xkcd git](../assets/git_commit.png) + +--- + +## Horizontal Rule + +--- + +## HTML + +
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.