diff --git a/.editorconfig b/.editorconfig index 37222d5..048fa3f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,167 +1,178 @@ -# 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 +############################################################ +# Basic file-type formatting +############################################################ -# XML project files -[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] -indent_style = space +[*.{json,yaml,yml}] indent_size = 2 -# XML files -[*.{xml,stylecop,resx,ruleset}] -indent_style = space +[*.md] indent_size = 2 -# JSON files -[*.{json,json5,webmanifest}] -indent_style = space +[*.{resx,ruleset,stylecop,xml,xsd,xsl}] indent_size = 2 -# YAML files -[*.{yml,yaml}] -indent_style = space +[*.{props,targets,config,nuspec}] indent_size = 2 -# Markdown files -[*.md] -trim_trailing_whitespace = false - -# Web files -[*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,svg,vue}] -indent_style = space -indent_size = 2 +[*.tsv] +indent_style = tab -# Bash scripts -[*.sh] -end_of_line = lf +############################################################ +# Standard formatting for C# and project files +############################################################ -# PowerShell files -[*.{ps1,psm1,psd1}] +[*.{cs,csproj}] indent_style = space indent_size = 4 +charset = utf-8 +end_of_line = crlf +insert_final_newline = true + +############################################################ +# C# / .NET defaults - consolidated for clarity +############################################################ -# C# code style rules [*.cs] -# Organize usings +# Set severity for all analyzers that are enabled by default (https://docs.microsoft.com/en-us/visualstudio/code-quality/use-roslyn-analyzers?view=vs-2022#set-rule-severity-of-multiple-analyzer-rules-at-once-in-an-editorconfig-file) +dotnet_analyzer_diagnostic.category-roslynator.severity = default|none|silent|suggestion|warning|error + +# Enable/disable all analyzers by default +# NOTE: This option can be used only in .roslynatorconfig file +roslynator_analyzers.enabled_by_default = true + +# Set severity for a specific analyzer +# dotnet_diagnostic..severity = default|none|silent|suggestion|warning|error + +# Enable/disable all refactorings +roslynator_refactorings.enabled = true + +# Enable/disable specific refactoring +# roslynator_refactoring..enabled = true|false + +# Enable/disable all compiler diagnostic fixes +roslynator_compiler_diagnostic_fixes.enabled = true + +# Enable/disable specific compiler diagnostic fix +# roslynator_compiler_diagnostic_fix..enabled = true|false + +# Sort using directives with System.* first dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = false - -# this. preferences -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:suggestion - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_predefined_type_for_member_access = true:suggestion - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion -dotnet_style_readonly_field = true:suggestion - -# Expression-level preferences -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_style_prefer_conditional_expression_over_return = true:silent - -# C# preferences -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion - -# Inlined variable declarations -csharp_style_inlined_variable_declaration = true:suggestion - -# Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion - -# C# formatting rules -csharp_new_line_before_open_brace = all + +# Default analyzer severity for most rules: suggestion (non-breaking for local dev) +dotnet_analyzer_diagnostic.severity = suggestion + +# categories: sensible defaults for repository quality +# Style: formatting and stylistic suggestions +dotnet_analyzer_diagnostic.category-Style.severity = warning + +# Performance: high-value performance rules treated as errors (e.g. hot-path logging) +dotnet_analyzer_diagnostic.category-Performance.severity = error + +# Naming: recommendations; keep as suggestions to avoid developer friction +dotnet_analyzer_diagnostic.category-Naming.severity = suggestion + +# Maintainability: suggestions for cleaner, maintainable code +dotnet_analyzer_diagnostic.category-Maintainability.severity = suggestion + +# Interoperability: warn about platform/interop issues +dotnet_analyzer_diagnostic.category-Interoperability.severity = warning + +# Design: API design and usage guidance +dotnet_analyzer_diagnostic.category-Design.severity = warning + +# Documentation: XML/docs guidance as suggestions +dotnet_analyzer_diagnostic.category-Documentation.severity = suggestion + +# Globalization: treat important globalization issues as errors (e.g. culture-sensitive formatting) +dotnet_analyzer_diagnostic.category-Globalization.severity = error + +# Reliability: lifecycle/dispose and other reliability concerns - warn by default +dotnet_analyzer_diagnostic.category-Reliability.severity = warning + +# Security: escalate security findings to errors +dotnet_analyzer_diagnostic.category-Security.severity = error + +# Usage: general API usage guidance +dotnet_analyzer_diagnostic.category-Usage.severity = warning + +# Modern C# style preferences (good defaults for .NET 10) +csharp_style_namespace_declarations = file_scoped +csharp_style_implicit_object_creation = true +csharp_style_target_typed_new_expression = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_not_pattern = true + +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_inlined_variable_declaration = true +csharp_style_throw_expression = true +csharp_style_prefer_switch_expression = true +csharp_prefer_simple_using_statement = true +csharp_style_prefer_pattern_matching = true +dotnet_style_operator_placement_when_wrapping = end_of_line + +# # Prefer pattern-matching null checks (`is null` / `is not null`) over `== null` or ReferenceEquals +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_diagnostic.IDE0041.severity = warning +# Also enable related null-check simplification rules +dotnet_diagnostic.IDE0029.severity = warning +dotnet_diagnostic.IDE0030.severity = warning +dotnet_diagnostic.IDE0270.severity = warning +dotnet_diagnostic.IDE0019.severity = warning + +# Prefer var when the type is apparent (modern and concise) +# how does this work with IDE0007? +# var and explicit typing preferences +# csharp_style_var_for_built_in_types = false:none +# csharp_style_var_when_type_is_apparent = true:suggestion +# csharp_style_var_elsewhere = false:suggestion +csharp_style_var_when_type_is_apparent = true + +# # Expression-bodied members where concise +csharp_style_expression_bodied_methods = when_on_single_line +csharp_style_expression_bodied_properties = when_on_single_line + +# # Naming conventions (kept as suggestions) +# dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion +# dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +# dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore +# dotnet_naming_symbols.private_fields.applicable_kinds = field +# dotnet_naming_symbols.private_fields.applicable_accessibilities = private +# dotnet_naming_style.camel_case_underscore.capitalization = camel_case +# dotnet_naming_style.camel_case_underscore.required_prefix = _ +# csharp_style_unused_value_expression_statement_preference = discard_variable + + +# Helpful IDE rules as suggestions so dotnet-format can apply fixes +dotnet_diagnostic.IDE0005.severity = suggestion # Remove unnecessary usings +dotnet_diagnostic.IDE0059.severity = suggestion # Unused assignment +dotnet_diagnostic.IDE0051.severity = suggestion # Unused private members +dotnet_diagnostic.IDE0060.severity = suggestion # Unused parameters +dotnet_diagnostic.IDE0058.severity = suggestion # Expression value is never used +dotnet_diagnostic.IDE0130.severity = suggestion # Use 'new' expression where possible (target-typed new) +csharp_style_unused_value_expression_statement_preference = unused_local_variable +# Nullable reference types - enabled as suggestions; project opt-in controls runtime enforcement +nullable = enable +csharp_style_prefer_primary_constructors = false + +# Formatting / newline preferences +# prefer Stroustrup +csharp_new_line_before_open_brace = false csharp_new_line_before_else = true csharp_new_line_before_catch = true csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true - +csharp_prefer_braces = when_multiline # Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true csharp_indent_switch_labels = true -csharp_indent_labels = flush_left - -# Space preferences -csharp_space_after_cast = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_around_binary_operators = before_and_after -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false - -# Wrapping preferences -csharp_preserve_single_line_statements = true -csharp_preserve_single_line_blocks = true - -# Code analysis rules -[*.cs] -# CA1303: Do not pass literals as localized parameters -dotnet_diagnostic.CA1303.severity = none +csharp_indent_labels = one_less_than_current -# 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 +# Using directive placement +csharp_using_directive_placement = outside_namespace diff --git a/.github/agents/csharp-dotnet-trackd.agent.md b/.github/agents/csharp-dotnet-trackd.agent.md new file mode 100644 index 0000000..95d262c --- /dev/null +++ b/.github/agents/csharp-dotnet-trackd.agent.md @@ -0,0 +1,117 @@ +--- +description: 'C#/.NET Expert (trackd)' +tools: ['search', 'vscode', 'edit', 'execute','read', 'search', 'web', 'execute', 'awesome-copilot/*', 'todo'] + + +--- +# C#/.NET Expert (trackd) + +You are in expert software engineer mode. Your task is to provide expert software engineering +guidance using modern software design patterns as if you were a leader in the field. + +You will provide: + +- insights, best practices and recommendations for .NET software engineering as if you were Anders + Hejlsberg, the original architect of C# and a key figure in the development of .NET as well as + Mads Torgersen, the lead designer of C#. +- general software engineering guidance and best-practices, clean code and modern software design, + as if you were Robert C. Martin (Uncle Bob), a renowned software engineer and author of "Clean + Code" and "The Clean Coder". +- DevOps and CI/CD best practices, as if you were Jez Humble, co-author of "Continuous Delivery" and + "The DevOps Handbook". +- Testing and test automation best practices, as if you were Kent Beck, the creator of Extreme + Programming (XP) and a pioneer in Test-Driven Development (TDD). +- Perform janitorial tasks on C#/.NET codebases. Focus on code cleanup, modernization, and technical + debt remediation. +- The only way to unload a powershell binary module is to restart the pwsh.exe that imported the dll, otherwise the build will fail cause the file is locked. typically you get around this buy running commands in a new pwsh.exe, like 'pwsh.exe -noprofile -command { }' +or Example: pwsh -NoProfile -File .\build.ps1 -Task Test -FullNameFilter 'Record type support' + +For .NET-specific guidance, focus on the following areas: + +- **Design Patterns**: Use and explain modern design patterns such as Async/Await, Dependency + Injection, Repository Pattern, Unit of Work, CQRS, Event Sourcing and of course the Gang of Four + patterns. +- **SOLID Principles**: Emphasize the importance of SOLID principles in software design, ensuring + that code is maintainable, scalable, and testable. +- **Testing**: Advocate for Test-Driven Development (TDD) and Behavior-Driven Development (BDD) + practices, using frameworks like xUnit, NUnit, or MSTest. +- **Performance**: Provide insights on performance optimization techniques, including memory + management, asynchronous programming, and efficient data access patterns. +- **Security**: Highlight best practices for securing .NET applications, including authentication, + authorization, and data protection. + +## Core Tasks + +### Code Modernization + +- Update to latest C# language features and syntax patterns +- Replace obsolete APIs with modern alternatives +- Convert to nullable reference types where appropriate +- Apply pattern matching and switch expressions +- Use collection expressions and primary constructors + +### Code Quality + +- Remove unused usings, variables, and members +- Fix naming convention violations (PascalCase, camelCase) +- Simplify LINQ expressions and method chains +- Apply consistent formatting and indentation +- Resolve compiler warnings and static analysis issues + +### Performance Optimization + +- Replace inefficient collection operations +- Use `StringBuilder` for string concatenation +- Apply `async`/`await` patterns correctly +- Optimize memory allocations and boxing +- Use `Span` and `Memory` where beneficial + +### Test Coverage + +- Identify missing test coverage +- Add unit tests for public APIs +- Create integration tests for critical workflows +- Apply AAA (Arrange, Act, Assert) pattern consistently +- Use FluentAssertions for readable assertions + +### Documentation + +- Add XML documentation comments +- Update README files and inline comments +- Document public APIs and complex algorithms +- Add code examples for usage patterns + +## Documentation Resources + +Use `microsoft.docs.mcp` tool to: + +- Look up current .NET best practices and patterns +- Find official Microsoft documentation for APIs +- Verify modern syntax and recommended approaches +- Research performance optimization techniques +- Check migration guides for deprecated features + +Query examples: + +- "C# nullable reference types best practices" +- ".NET performance optimization patterns" +- "async await guidelines C#" +- "LINQ performance considerations" + +## Execution Rules + +1. **Validate Changes**: Run tests after each modification +2. **Incremental Updates**: Make small, focused changes +3. **Preserve Behavior**: Maintain existing functionality +4. **Follow Conventions**: Apply consistent coding standards +5. **Safety First**: Backup before major refactoring + +## Analysis Order + +1. Scan for compiler warnings and errors +2. Identify deprecated/obsolete usage +3. Check test coverage gaps +4. Review performance bottlenecks +5. Assess documentation completeness + +Apply changes systematically, testing after each modification. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index b361f07..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,142 +0,0 @@ - - ---- -description: 'Guidelines for building C# applications' -applyTo: '**/*.cs' ---- - -# C# Development - -## C# Instructions -- Always use the latest version C#, currently C# 13 features. -- Write clear and concise comments for each function. - -## General Instructions -- Make only high confidence suggestions when reviewing code changes. -- Write code with good maintainability practices, including comments on why certain design decisions were made. -- Handle edge cases and write clear exception handling. -- For libraries or external dependencies, mention their usage and purpose in comments. - -## Naming Conventions - -- Follow PascalCase for component names, method names, and public members. -- Use camelCase for private fields and local variables. -- Prefix interface names with "I" (e.g., IUserService). - -## Formatting - -- Apply code-formatting style defined in `.editorconfig`. -- Prefer file-scoped namespace declarations and single-line using directives. -- Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.). -- Ensure that the final return statement of a method is on its own line. -- Use pattern matching and switch expressions wherever possible. -- Use `nameof` instead of string literals when referring to member names. -- Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. - -## Project Setup and Structure - -- Guide users through creating a new .NET project with the appropriate templates. -- Explain the purpose of each generated file and folder to build understanding of the project structure. -- Demonstrate how to organize code using feature folders or domain-driven design principles. -- Show proper separation of concerns with models, services, and data access layers. -- Explain the Program.cs and configuration system in ASP.NET Core 9 including environment-specific settings. - -## Nullable Reference Types - -- Declare variables non-nullable, and check for `null` at entry points. -- Always use `is null` or `is not null` instead of `== null` or `!= null`. -- Trust the C# null annotations and don't add null checks when the type system says a value cannot be null. - -## Data Access Patterns - -- Guide the implementation of a data access layer using Entity Framework Core. -- Explain different options (SQL Server, SQLite, In-Memory) for development and production. -- Demonstrate repository pattern implementation and when it's beneficial. -- Show how to implement database migrations and data seeding. -- Explain efficient query patterns to avoid common performance issues. - -## Authentication and Authorization - -- Guide users through implementing authentication using JWT Bearer tokens. -- Explain OAuth 2.0 and OpenID Connect concepts as they relate to ASP.NET Core. -- Show how to implement role-based and policy-based authorization. -- Demonstrate integration with Microsoft Entra ID (formerly Azure AD). -- Explain how to secure both controller-based and Minimal APIs consistently. - -## Validation and Error Handling - -- Guide the implementation of model validation using data annotations and FluentValidation. -- Explain the validation pipeline and how to customize validation responses. -- Demonstrate a global exception handling strategy using middleware. -- Show how to create consistent error responses across the API. -- Explain problem details (RFC 7807) implementation for standardized error responses. - -## API Versioning and Documentation - -- Guide users through implementing and explaining API versioning strategies. -- Demonstrate Swagger/OpenAPI implementation with proper documentation. -- Show how to document endpoints, parameters, responses, and authentication. -- Explain versioning in both controller-based and Minimal APIs. -- Guide users on creating meaningful API documentation that helps consumers. - -## Logging and Monitoring - -- Guide the implementation of structured logging using Serilog or other providers. -- Explain the logging levels and when to use each. -- Demonstrate integration with Application Insights for telemetry collection. -- Show how to implement custom telemetry and correlation IDs for request tracking. -- Explain how to monitor API performance, errors, and usage patterns. - -## Testing - -- Always include test cases for critical paths of the application. -- Guide users through creating unit tests. -- Do not emit "Act", "Arrange" or "Assert" comments. -- Copy existing style in nearby files for test method names and capitalization. -- Explain integration testing approaches for API endpoints. -- Demonstrate how to mock dependencies for effective testing. -- Show how to test authentication and authorization logic. -- Explain test-driven development principles as applied to API development. - -## Performance Optimization - -- Guide users on implementing caching strategies (in-memory, distributed, response caching). -- Explain asynchronous programming patterns and why they matter for API performance. -- Demonstrate pagination, filtering, and sorting for large data sets. -- Show how to implement compression and other performance optimizations. -- Explain how to measure and benchmark API performance. - -## Deployment and DevOps - -- Guide users through containerizing their API using .NET's built-in container support (`dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer`). -- Explain the differences between manual Dockerfile creation and .NET's container publishing features. -- Explain CI/CD pipelines for NET applications. -- Demonstrate deployment to Azure App Service, Azure Container Apps, or other hosting options. -- Show how to implement health checks and readiness probes. -- Explain environment-specific configurations for different deployment stages. - - -### Target Framework -- **Target**: .NET 8.0 (`net8.0`) -- **Language Version**: C# 13.0 (latest features enabled) -- **Compatibility**: PowerShell 7.4+ -- **Benefits**: Modern performance, latest C# features, advanced optimization - -## Development Guidelines - -### PowerShell Best Practices -- Use proper PowerShell parameter validation attributes -- Include comprehensive help text for all parameters -- Handle errors gracefully with meaningful error messages -- Support pipeline input where appropriate (`ValueFromPipeline`, `ValueFromPipelineByPropertyName`), alias parameters where applicable to match property names on input objects -- Use `WriteVerbose` for debugging information -- Use `WriteWarning` for non-fatal issues -- Use `WriteError` for proper error handling with `ErrorRecord` -- Support `-WhatIf` and `-Confirm` parameters for cmdlets that modify state -- Use `OutputType` attribute to specify the type of output returned by cmdlets diff --git a/.gitignore b/.gitignore index 8746b0c..ec061ea 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ src/obj/* src/.vs/* Module/lib debug.md -output/* +[Oo]utput/ */obj/* */bin/* +.github/chatmodes/* +.github/instructions/* +.github/prompts/* +ref/** +Copilot-Processing.md +tools/** diff --git a/Module/PSTextMate.format.ps1xml b/Module/PSTextMate.format.ps1xml index af6082c..506549d 100644 --- a/Module/PSTextMate.format.ps1xml +++ b/Module/PSTextMate.format.ps1xml @@ -42,73 +42,101 @@ - TextMateDebug + Grapheme - PwshSpectreConsole.TextMate.Test+TextMateDebug + PSTextMate.Helpers.GraphemeMeasurement - + + Center - + + + + + + + + - + + + + - - return 'Line {0}, Index {1}' -f $_.LineIndex, ($_.StartIndex, $_.EndIndex -join '-') - + StringLength - Text + Cells - - return $_.Scopes -join ', ' - + Length - - - - - - - TextMateDebug - - PwshSpectreConsole.TextMate.Core.TokenDebugInfo - - - - - - - - - - - - - + - Text + + + # HasWideCharacters + if ($_.HasWideCharacters) { + '{1}{0}{2}' -f ( + [char]0xf42e, + "$([char]27)[32m", + "$([char]27)[0m" + ) + } + else { + '{1}{0}{2}' -f ( + [char]0xEA87, + "$([char]27)[31m", + "$([char]27)[0m" + ) + } + + - return 'fg: {0}, bg: {1}, deco: {2}, link: {3}' -f ( - $_.Style.Foreground.ToMarkup(), - $_.Style.Background.ToMarkup(), - $_.Style.Decoration, - $_.Style.Link) - + # ContainsVT + if ($null -eq $_.ContainsVT) { + return '{1}{0}{2}' -f ( + [Char]::ConvertFromUtf32(985058), + "$([char]27)[33m", + "$([char]27)[0m" + ) + } + if ($_.ContainsVT) { + '{1}{0}{2}' -f ( + [char]0xf42e, + "$([char]27)[32m", + "$([char]27)[0m" + ) + } + else { + '{1}{0}{2}' -f ( + [char]0xEA87, + "$([char]27)[31m", + "$([char]27)[0m" + ) + } + - + + Text + + diff --git a/Module/PSTextMate.psd1 b/Module/PSTextMate.psd1 index 5091b1e..a9b8b9c 100644 --- a/Module/PSTextMate.psd1 +++ b/Module/PSTextMate.psd1 @@ -1,21 +1,34 @@ @{ - RootModule = 'PSTextMate.dll' - ModuleVersion = '0.0.3' - GUID = '5ba21f1d-5ca4-49df-9a07-a2ad379feb00' + RootModule = 'lib/PSTextMate.dll' + ModuleVersion = '0.1.0' + GUID = 'a6490f8a-1f53-44f2-899c-bf66b9c6e608' Author = 'trackd' CompanyName = 'trackd' Copyright = '(c) trackd. All rights reserved.' PowerShellVersion = '7.4' CompatiblePSEditions = 'Core' - CmdletsToExport = 'Show-TextMate', 'Test-SupportedTextMate', 'Get-SupportedTextMate', 'Debug-TextMate', 'Debug-TextMateTokens','Debug-SixelSupport','Test-ImageRendering' - AliasesToExport = '*' - RequiredAssemblies = './lib/TextMateSharp.dll', './lib/TextMateSharp.Grammars.dll', './lib/Onigwrap.dll', 'Markdig.Signed.dll' + CmdletsToExport = @( + 'Show-TextMate' + 'Test-SupportedTextMate' + 'Get-SupportedTextMate' + 'Format-CSharp' + 'Format-Markdown' + 'Format-PowerShell' + ) + AliasesToExport = @( + 'fcs' + 'fmd' + 'fps' + 'stm' + 'Show-Code' + ) + RequiredAssemblies = @() FormatsToProcess = 'PSTextMate.format.ps1xml' RequiredModules = @( @{ ModuleName = 'PwshSpectreConsole' - ModuleVersion = '2.1.0' - MaximumVersion = '2.9.9' + ModuleVersion = '2.3.0' + MaximumVersion = '2.99.99' } ) } diff --git a/PSTextMate.build.ps1 b/PSTextMate.build.ps1 new file mode 100644 index 0000000..b21b1ab --- /dev/null +++ b/PSTextMate.build.ps1 @@ -0,0 +1,138 @@ +#! /usr/bin/pwsh +#Requires -Version 7.4 -Module InvokeBuild +param( + [string]$Configuration = 'Release', + [switch]$SkipHelp, + [switch]$SkipTests +) +Write-Host "$($PSBoundParameters.GetEnumerator())" -ForegroundColor Cyan + +$modulename = [System.IO.Path]::GetFileName($PSCommandPath) -replace '\.build\.ps1$' + +$script:folders = @{ + ModuleName = $modulename + ProjectRoot = $PSScriptRoot + TempLib = Join-Path $PSScriptRoot 'templib' + SourcePath = Join-Path $PSScriptRoot 'src' + OutputPath = Join-Path $PSScriptRoot 'output' + DestinationPath = Join-Path $PSScriptRoot 'output' 'lib' + ModuleSourcePath = Join-Path $PSScriptRoot 'module' + DocsPath = Join-Path $PSScriptRoot 'docs' 'en-US' + TestPath = Join-Path $PSScriptRoot 'tests' + CsprojPath = Join-Path $PSScriptRoot 'src' "$modulename.csproj" +} + +task Clean { + if (Test-Path $folders.OutputPath) { + Remove-Item -Path $folders.OutputPath -Recurse -Force -ErrorAction 'Ignore' + } + New-Item -Path $folders.OutputPath -ItemType Directory -Force | Out-Null +} + + +task Build { + if (-not (Test-Path $folders.CsprojPath)) { + Write-Warning 'C# project not found, skipping Build' + return + } + exec { dotnet publish $folders.CsprojPath --configuration $Configuration --nologo --verbosity minimal --output $folders.TempLib } + $null = New-Item -Path $folders.outputPath -ItemType Directory -Force + $rids = @('win-x64', 'osx-arm64', 'linux-x64','linux-arm64','win-arm64') + foreach ($rid in $rids) { + $ridDest = Join-Path $folders.DestinationPath $rid + $null = New-Item -Path $ridDest -ItemType Directory -Force + $nativePath = Join-Path $folders.TempLib 'runtimes' $rid 'native' + Get-ChildItem -Path $nativePath -File | Move-Item -Destination $ridDest -Force + } + Get-ChildItem -Path $folders.TempLib -File | Move-Item -Destination $folders.DestinationPath -Force + if (Test-Path -Path $folders.TempLib -PathType Container) { + Remove-Item -Path $folders.TempLib -Recurse -Force -ErrorAction 'Ignore' + } +} + +task ModuleFiles { + if (Test-Path $folders.ModuleSourcePath) { + Get-ChildItem -Path $folders.ModuleSourcePath -File | Copy-Item -Destination $folders.OutputPath -Force + } + else { + Write-Warning "Module directory not found at: $($folders.ModuleSourcePath)" + } +} + +task GenerateHelp -if (-not $SkipHelp) { + if (-not (Test-Path $folders.DocsPath)) { + Write-Warning "Documentation path not found at: $($folders.DocsPath)" + return + } + if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PlatyPS)) { + Write-Host ' Installing Microsoft.PowerShell.PlatyPS...' -ForegroundColor Yellow + Install-Module -Name Microsoft.PowerShell.PlatyPS -Scope CurrentUser -Force -AllowClobber + } + + Import-Module Microsoft.PowerShell.PlatyPS -ErrorAction Stop + + $modulePath = Join-Path $folders.OutputPath ($folders.ModuleName + '.psd1') + if (-not (Test-Path $modulePath)) { + Write-Warning "Module manifest not found at: $modulePath. Skipping help generation." + return + } + + if (-Not (Get-Module PwshSpectreConsole -ListAvailable)) { + # just temporarily while im refactoring the PwshSpectreConsole module. + $ParentPath = Split-Path $folders.ProjectRoot -Parent + Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + } + + Import-Module $modulePath -Force + + $helpOutputPath = Join-Path $folders.OutputPath 'en-US' + New-Item -Path $helpOutputPath -ItemType Directory -Force | Out-Null + + $allCommandHelp = Get-ChildItem -Path $folders.DocsPath -Filter '*.md' -Recurse -File | + Where-Object { $_.Name -ne "$($folders.ModuleName).md" } | + Import-MarkdownCommandHelp + + if ($allCommandHelp.Count -gt 0) { + $tempOutputPath = Join-Path $helpOutputPath 'temp' + Export-MamlCommandHelp -CommandHelp $allCommandHelp -OutputFolder $tempOutputPath -Force | Out-Null + + $generatedFile = Get-ChildItem -Path $tempOutputPath -Filter '*.xml' -Recurse -File | Select-Object -First 1 + if ($generatedFile) { + Move-Item -Path $generatedFile.FullName -Destination $helpOutputPath -Force + } + Remove-Item -Path $tempOutputPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +task Test -if (-not $SkipTests) { + if (-not (Test-Path $folders.TestPath)) { + Write-Warning "Test directory not found at: $($folders.TestPath)" + return + } + + if (-not (Get-Module PwshSpectreConsole -ListAvailable)) { + # just temporarily while im refactoring the PwshSpectreConsole module. + $ParentPath = Split-Path $folders.ProjectRoot -Parent + Import-Module (Join-Path $ParentPath 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + } + + Import-Module (Join-Path $folders.OutputPath 'PSTextMate.psd1') -ErrorAction Stop + Import-Module (Join-Path $folders.TestPath 'testhelper.psm1') -ErrorAction Stop + + $pesterConfig = New-PesterConfiguration + # $pesterConfig.Output.Verbosity = 'Detailed' + $pesterConfig.Run.Path = $folders.TestPath + $pesterConfig.Run.Throw = $true + $pesterConfig.Debug.WriteDebugMessages = $false + Invoke-Pester -Configuration $pesterConfig +} + +task CleanAfter { + if ($script:folders.DestinationPath -and (Test-Path $script:folders.DestinationPath)) { + Get-ChildItem $script:folders.DestinationPath -File -Recurse | Where-Object { $_.Extension -in '.pdb', '.json' } | Remove-Item -Force -ErrorAction Ignore + } +} + + +task All -Jobs Clean, Build, ModuleFiles, GenerateHelp, CleanAfter , Test +task BuildAndTest -Jobs Clean, Build, ModuleFiles, CleanAfter #, Test diff --git a/PSTextMate.generated.sln b/PSTextMate.generated.sln deleted file mode 100644 index 6e7fe2e..0000000 --- a/PSTextMate.generated.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PSTextMate", "src\PSTextMate.csproj", "{F9DA8F41-8002-4A61-A0BD-7460B90950F7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F9DA8F41-8002-4A61-A0BD-7460B90950F7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E06DE8A7-9359-41CD-923E-6201952ECDFD} - EndGlobalSection -EndGlobal diff --git a/PSTextMate.slnx b/PSTextMate.slnx new file mode 100644 index 0000000..ef6d148 --- /dev/null +++ b/PSTextMate.slnx @@ -0,0 +1,3 @@ + + + diff --git a/README.md b/README.md index 7008e55..76cf62f 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,77 @@ # PSTextMate -This modules allows you to render content in the console using TextMate. -see below table for supported grammars and builtin themes. - -the module is meant to be paired with PwshSpectreConsole. - -this is still in alpha stage, so expect breaking changes. - -todo: add custom grammars, add custom themes. - -## libraries - -[TextMateSharp](https://github.com/danipen/TextMateSharp) -[PwshSpectreConsole](https://github.com/ShaunLawrie/PwshSpectreConsole) -[SpectreConsole](https://github.com/spectreconsole/spectre.console) - -## Grammars and Themes - -| grammars | themes | -|---------------|-------------------| -| asciidoc | Abbys | -| bat | Dark | -| clojure | DarkPlus | -| coffeescript | DimmedMonokai | -| cpp | KimbieDark | -| csharp | Light | -| css | LightPlus | -| dart | Monokai | -| diff | QuietLight | -| docker | Red | -| fsharp | SolarizedDark | -| git | SolarizedLight | -| go | TomorrowNightBlue | -| groovy | HighContrastLight | -| handlebars | HighContrastDark | -| hlsl | | -| html | | -| ini | | -| java | | -| javascript | | -| json | | -| julia | | -| latex | | -| less | | -| log | | -| lua | | -| make | | -| markdown | | -| markdown-math | | -| objective-c | | -| pascal | | -| perl | | -| php | | -| powershell | | -| pug | | -| python | | -| r | | -| razor | | -| ruby | | -| rust | | -| scss | | -| shaderlab | | -| shellscript | | -| sql | | -| swift | | -| typescript | | -| vb | | -| xml | | -| yaml | | +PSTextMate delivers syntax-aware highlighting for PowerShell on top of TextMate grammars. It exposes a focused set of cmdlets that emit tokenized, theme-styled `HighlightedText` renderables you can write with PwshSpectreConsole or feed into any Spectre-based pipeline. Helper cmdlets make it easy to discover grammars and validate support for files, extensions, or language IDs before formatting. + +What it does + +- Highlights source text using TextMate grammars such as PowerShell, C#, Markdown, and Python. +- Returns `HighlightedText` renderables that implement Spectre.Console's contract, so they can be written through PwshSpectreConsole or other Spectre hosts. +- Provides discovery and testing helpers for installed grammars, extensions, or language IDs. +- Does inline Sixel images in markdown + +## Cmdlets + +| Cmdlet | Purpose | +|--------|---------| +| [Format-CSharp](docs/en-us/Format-CSharp.md) | Highlight C# source | +| [Format-Markdown](docs/en-us/Format-Markdown.md) | Highlight Markdown content | +| [Format-PowerShell](docs/en-us/Format-PowerShell.md) | Highlight PowerShell code | +| [Show-TextMate](docs/en-us/Show-TextMate.md) | Render text with an inferred or explicit language. | +| [Get-SupportedTextMate](docs/en-us/Get-SupportedTextMate.md) | List available grammars and file extensions. | +| [Test-SupportedTextMate](docs/en-us/Test-SupportedTextMate.md) | Check support for a file, extension, or language ID. | + +## Examples + +```powershell +# highlight a C# snippet +"public class C { void M() {} }" | Format-CSharp + +# render a Markdown file with a theme +Get-Content README.md -Raw | Format-Markdown -Theme SolarizedLight + +# list supported grammars +Get-SupportedTextMate +``` + +## Installation + +```powershell +Install-Module PSTextMate +``` + +### Prerequisites + +- **PowerShell**: 7.4 + +### Building from Source + +1. Clone this repository +2. Open a terminal in the project directory +3. Build the project: + +```powershell +& .\build.ps1 +``` + +1. Import the module: + +```powershell +Import-Module .\output\PSTextMate.psd1 +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Update documentation as needed +5. Submit a pull request + +## Dependencies + +- [TextMateSharp](https://github.com/danipen/TextMateSharp) + - [OnigWrap](https://github.com/aikawayataro/Onigwrap) +- [PwshSpectreConsole](https://github.com/ShaunLawrie/PwshSpectreConsole) + - [SpectreConsole](https://github.com/spectreconsole/spectre.console) + +--- diff --git a/assets/git_commit.png b/assets/git_commit.png new file mode 100644 index 0000000..09afce9 Binary files /dev/null and b/assets/git_commit.png differ diff --git a/build.ps1 b/build.ps1 index 6d85a34..d887721 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,42 +1,44 @@ -#Requires -Version 7.4 -if (-Not $PSScriptRoot) { - return 'Run this script from the root of the project' -} -$ErrorActionPreference = 'Stop' -Push-Location $PSScriptRoot - -dotnet clean -dotnet restore +#! /usr/bin/pwsh +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + [switch]$SkipHelp, + [switch]$SkipTests, + [Switch]$BuildAndTestOnly, + [string] $Task +) -$ModuleFilesFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Module' -if (-Not (Test-Path $ModuleFilesFolder)) { - $null = New-Item -ItemType Directory -Path $ModuleFilesFolder -Force +$ErrorActionPreference = 'Stop' +# Helper function to get paths +$buildparams = @{ + Configuration = $Configuration + SkipHelp = $SkipHelp.IsPresent + SkipTests = $SkipTests.IsPresent + File = Join-Path $PSScriptRoot 'PSTextMate.build.ps1' + Task = 'All' + Result = 'Result' + Safe = $true } -Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -File -Recurse | Remove-Item -Force - -$moduleLibFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Output' | Join-Path -ChildPath 'lib' -if (-Not (Test-Path $moduleLibFolder)) { - $null = New-Item -ItemType Directory -Path $moduleLibFolder -Force +if (-not (Get-Module -ListAvailable -Name InvokeBuild)) { + Install-Module -Name InvokeBuild -Scope CurrentUser -Force -AllowClobber } +Import-Module InvokeBuild -ErrorAction Stop -$csproj = Get-Item (Join-Path -Path $PSScriptRoot -ChildPath 'src' | Join-Path -ChildPath 'PSTextMate.csproj') -$outputfolder = Join-Path -Path $PSScriptRoot -ChildPath 'packages' -if (-Not (Test-Path -Path $outputfolder)) { - $null = New-Item -ItemType Directory -Path $outputfolder -Force +if ($task) { + $buildparams.Task = $task +} +elseif ($BuildAndTestOnly) { + $buildparams.Task = 'BuildAndTest' } -dotnet publish $csproj.FullName -c Release -o $outputfolder -Copy-Item -Path $ModuleFilesFolder/* -Destination (Join-Path -Path $PSScriptRoot -ChildPath 'Output') -Force -Recurse -Include '*.psd1', '*.psm1', '*.ps1xml' - -Get-ChildItem -Path $moduleLibFolder -File | Remove-Item -Force - - -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'win-x64' | Join-Path -ChildPath 'native') -Filter *.dll | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'osx' | Join-Path -ChildPath 'native') -Filter *.dylib | Move-Item -Destination $moduleLibFolder -Force -Get-ChildItem -Path (Join-Path -Path $outputfolder -ChildPath 'runtimes' | Join-Path -ChildPath 'linux-x64' | Join-Path -ChildPath 'native') -Filter *.so | Copy-Item -Destination $moduleLibFolder -Force -Move-Item (Join-Path -Path $outputfolder -ChildPath 'PSTextMate.dll') -Destination (Split-Path $moduleLibFolder) -Force -Get-ChildItem -Path $outputfolder -File | - Where-Object { -Not $_.Name.StartsWith('System.Text') -And $_.Extension -notin '.json','.pdb' } | - Move-Item -Destination $moduleLibFolder -Force - -Pop-Location +if (-not $env:CI) { + # this is just so the dll doesn't get locked on and i can rebuild without restarting terminal + $sb = { + param($bp) + Invoke-Build @bp + } + pwsh -NoProfile -Command $sb -args $buildparams +} +else { + Invoke-Build @buildparams +} diff --git a/debug.ps1 b/debug.ps1 deleted file mode 100644 index ca38645..0000000 --- a/debug.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -<# -$lookup = [TextMateSharp.Grammars.RegistryOptions]::new('red') -$reg = [TextMateSharp.Registry.Registry]::new($lookup) -$theme = $reg.GetTheme() -$theme | Get-Member -MemberType Method -$grammar = $reg.LoadGrammar($lookup.GetScopeByExtension('.md')) -$grammar | Get-Member -MemberType Method -#> - -Push-Location $PSScriptRoot -& ./build.ps1 - -Import-Module ./Module/PSTextMate.psd1 -$md = @' -[fancy title](https://www.google.com) -'@ - -[PwshSpectreConsole.TextMate.Test]::DebugTextMate($md, [TextMateSharp.Grammars.ThemeName]::Dark, 'markdown') -Pop-Location - - -# $x = [Spectre.Console.Style]::new([Spectre.Console.Color]::Aqua, [Spectre.Console.Color]::Default, [Spectre.Console.Decoration]::Underline, 'https://foo.bar') -# [Spectre.Console.Markup]::new('hello', $x) -# [Spectre.Console.Markup]::new("[link=https://foo.com]$([Spectre.Console.Markup]::escape('[foo]'))[/]") -# [Spectre.Console.Markup]::new("[link=https://foo.com]$([Spectre.Console.Markup]::escape('[foo]'))[/]") -# [Spectre.Console.Markup]::new('[link=https://foo.com]foo[/]') diff --git a/docs/en-us/Format-CSharp.md b/docs/en-us/Format-CSharp.md new file mode 100644 index 0000000..fbcaa1d --- /dev/null +++ b/docs/en-us/Format-CSharp.md @@ -0,0 +1,159 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Format-CSharp +--- + +# Format-CSharp + +## SYNOPSIS + +Renders C# source code using TextMate grammars and returns a PSTextMate.Core.HighlightedText object. +Use for previewing or formatting C# snippets and files. + +## SYNTAX + +### (All) + +``` +Format-CSharp [-InputObject] [-Theme ] [-LineNumbers] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + fcs + +## DESCRIPTION + +Format-CSharp renders C# input using the TextMate grammar for C#. Input can be provided as objects (strings) via the pipeline or by passing file contents. +The cmdlet produces a `HighlightedText` object suitable for rendering to console. +Use `-Theme` to select a visual theme and `-LineNumbers` to include line numbers in the output. + +## EXAMPLES + +### Example 1 + +Example: highlight a C# snippet from the pipeline + +``` +"public class C { void M() {} }" | Format-CSharp +``` + +### Example 2 + +Example: format a file and include line numbers + +``` +Get-Content .\src\Program.cs -Raw | Format-CSharp -LineNumbers +``` + +### Example 3 + +Example: Pipe FileInfo objects + +``` +Get-ChildItem *.cs | Format-CSharp -Theme SolarizedDark +``` + +## PARAMETERS + +### -InputObject + +Accepts a string or object containing source code. +When receiving pipeline input, the cmdlet treats the value as source text. +For file processing, pass the file contents (for example with `Get-Content -Raw`). +FileInfo objects are also accepted + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +When specified, include line numbers in the rendered output to aid reference and diffs. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Selects a `TextMateSharp.Grammars.ThemeName` to use when rendering. If omitted, the module default theme is used. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts any object that can be converted to or contains a code string; commonly a `string` produced by `Get-Content -Raw` or piped literal text. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns a `HighlightedText` object which contains the tokenized and styled representation of the input. This object is intended for rendering to consoles or for downstream processing. + +## NOTES + +This cmdlet uses TextMate grammars packaged with the module. For large files consider streaming the contents or increasing process memory limits. + +## RELATED LINKS + +See also `Format-PowerShell` and `Format-Markdown` for other language renderers. diff --git a/docs/en-us/Format-Markdown.md b/docs/en-us/Format-Markdown.md new file mode 100644 index 0000000..d051776 --- /dev/null +++ b/docs/en-us/Format-Markdown.md @@ -0,0 +1,180 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Format-Markdown +--- + +# Format-Markdown + +## SYNOPSIS + +Renders Markdown input using TextMate grammars or the module's alternate renderer and returns a PSTextMate.Core.HighlightedText object. + +## SYNTAX + +### (All) + +``` +Format-Markdown [-InputObject] [-Alternate] [-Theme ] [-LineNumbers] + [] +``` + +## ALIASES + +This cmdlet has the following aliases, + fmd + +## DESCRIPTION + +Format-Markdown highlights Markdown content using the Markdown grammar where appropriate. +Use the `-Alternate` switch to force TextMate renderer as opposed to custom Markdig rendering. +Input may be piped in as text or read from files. +The cmdlet returns a `HighlightedText` object for rendering. + +## EXAMPLES + +### Example 1 + +Example: highlight a Markdown string + +``` +"# Title`n`n- item1`n- item2" | Format-Markdown +``` + +### Example 2 + +Example: format a file using the alternate renderer + +``` +Get-Content README.md -Raw | Format-Markdown -Alternate +``` + +### Example 3 + +Example: pipe FileInfo object and use a theme and line numbers + +``` +Get-ChildItem docs\guide.md | Format-Markdown -Theme SolarizedLight -LineNumbers +``` + +## PARAMETERS + +### -InputObject + +Accepts a string or object containing Markdown text. Common usage is `Get-Content -Raw` piped into the cmdlet. +FileInfo objects are also accepted + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +Includes line numbers in the rendered output when specified. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Selects a `TextMateSharp.Grammars.ThemeName` to style the output. If omitted, the module default is used. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Alternate + +When present, forces the module's TextMate rendering instead of the custom Markdig rendering path. +Useful for testing alternate presentation. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts Markdown text as a `string` or an object that can provide textual content. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns a `HighlightedText` object representing the highlighted Markdown content. + +## NOTES + +The Markdown renderer may apply special handling for fenced code blocks and front-matter when `UsesMarkdownBaseDirectory` is enabled. Use `-Alternate` to bypass markdown-specific rendering. + +## RELATED LINKS + +See `Format-CSharp` and `Format-PowerShell` for language-specific renderers. diff --git a/docs/en-us/Format-PowerShell.md b/docs/en-us/Format-PowerShell.md new file mode 100644 index 0000000..29a6b89 --- /dev/null +++ b/docs/en-us/Format-PowerShell.md @@ -0,0 +1,155 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Format-PowerShell +--- + +# Format-PowerShell + +## SYNOPSIS + +Renders PowerShell code using TextMate grammars and returns a PSTextMate.Core.HighlightedText result for display or programmatic use. + +## SYNTAX + +### (All) + +``` +Format-PowerShell [-InputObject] [-Theme ] [-LineNumbers] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + fps + +## DESCRIPTION + +Format-PowerShell highlights PowerShell source and script files. Input can be provided as pipeline text or via file contents. The resulting `HighlightedText` can be used with console renderers or further processed. Use `-Theme` and `-LineNumbers` to adjust output. + +## EXAMPLES + +### Example 1 + +Example: highlight a short PowerShell snippet + +``` +'Get-Process | Where-Object { $_.CPU -gt 1 }' | Format-PowerShell +``` + +### Example 2 + +Example: highlight a script file with line numbers + +``` +Get-Content .\scripts\deploy.ps1 -Raw | Format-PowerShell -LineNumbers +``` + +### Example 3 + +Example: Pipe FileInfo object and render with theme. + +``` +Get-ChildItem .\scripts\*.ps1 | Format-PowerShell -Theme Monokai +``` + +## PARAMETERS + +### -InputObject + +Accepts a `string` or object containing PowerShell source text. +Typically used with `Get-Content -Raw` or piping literal strings. +Accepts FileInfo objects + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +When present, include line numbers in the rendered output. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Chooses a `TextMateSharp.Grammars.ThemeName` for styling the highlighted output. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.Management.Automation.PSObject + +Accepts textual input representing PowerShell source. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns the highlighted representation of the input source as a `HighlightedText` object. + +## NOTES + +The cmdlet uses the PowerShell grammar shipped with the module. For very large scripts consider chunking input to avoid high memory usage. + +## RELATED LINKS + +See `Format-CSharp` and `Format-Markdown` for other language renderers. diff --git a/docs/en-us/Get-SupportedTextMate.md b/docs/en-us/Get-SupportedTextMate.md new file mode 100644 index 0000000..1e00417 --- /dev/null +++ b/docs/en-us/Get-SupportedTextMate.md @@ -0,0 +1,84 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Get-SupportedTextMate +--- + +# Get-SupportedTextMate + +## SYNOPSIS + +Retrieves a list of supported TextMate languages and grammar metadata available to the module. + +## SYNTAX + +### (All) + +``` +Get-SupportedTextMate [] +``` + +## ALIASES + +This cmdlet has the following aliases, + None + +## DESCRIPTION + +Get-SupportedTextMate returns detailed `TextMateSharp.Grammars.Language` objects describing available grammars, file extensions, scopes, and other metadata. Useful for tooling that needs to map file types to TextMate language IDs. + +## EXAMPLES + +### Example 1 + +Example: list all supported languages + +``` +Get-SupportedTextMate +``` + +### Example 2 + +Example: show language names and extensions + +``` +Get-SupportedTextMate | Select-Object Name, Extensions +``` + +### Example 3 + +Example: find languages supporting .cs files + +``` +Get-SupportedTextMate | Where-Object { $_.Extensions -contains '.cs' } +``` + +## PARAMETERS + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### TextMateSharp.Grammars.Language + +Emits `Language` objects from TextMateSharp describing the grammar, scope name, file extensions and any available configuration. + +## NOTES + +The returned objects can be used by `Show-TextMate` or other consumers to determine which grammar token to apply for a given file. + +## RELATED LINKS + +See `Show-TextMate` for rendering and `Test-SupportedTextMate` for support checks. diff --git a/docs/en-us/Show-TextMate.md b/docs/en-us/Show-TextMate.md new file mode 100644 index 0000000..82992a9 --- /dev/null +++ b/docs/en-us/Show-TextMate.md @@ -0,0 +1,201 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Show-TextMate +--- + +# Show-TextMate + +## SYNOPSIS + +Displays syntax-highlighted text using TextMate grammars. Accepts strings or file input and returns a `HighlightedText` object for rendering. + +## SYNTAX + +### Default (Default) + +``` +Show-TextMate [-InputObject] [-Language ] [-Alternate] [-Theme ] + [-LineNumbers] [] +``` + +## ALIASES + +This cmdlet has the following aliases, + stm, Show-Code + +## DESCRIPTION + +Show-TextMate renders textual input using an appropriate TextMate grammar. +When `-Language` is provided it forces that language; +when omitted the cmdlet may infer language from file extension or default to `powershell`. +Use `-Alternate` to force the standard renderer for Markdown files. + +## EXAMPLES + +### Example 1 + +Example: highlight a snippet with an explicit language + +``` +"print('hello')" | Show-TextMate -Language python +``` + +### Example 2 + +Example: render a file and let the cmdlet infer language from extension + +``` +Show-TextMate -InputObject (Get-Content scripts\deploy.ps1 -Raw) +``` + +### Example 3 + +Example: preview a Markdown file + +``` +Get-Content README.md -Raw | Show-TextMate -Theme SolarizedDark +``` + +## PARAMETERS + +### -Alternate + +Forces the standard (non-markdown-specialized) renderer. Useful for previewing how code blocks will appear under the generic renderer. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -InputObject + +Accepts a `string` or object containing textual content. +Use `Get-Content -Raw` to pass file contents. + +```yaml +Type: PSObject +DefaultValue: '' +SupportsWildcards: false +Aliases: +- FullName +- Path +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Language + +Hint to force a particular TextMate language ID (for example `powershell`, `csharp`, `python`). +When provided it overrides extension-based inference. + +```yaml +Type: String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -LineNumbers + +Include line numbers in the output when specified. + +```yaml +Type: SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Theme + +Select a `TextMateSharp.Grammars.ThemeName` used for styling output. + +```yaml +Type: TextMateSharp.Grammars.ThemeName +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSObject + +Accepts textual input or objects containing text; commonly used with `Get-Content -Raw` or pipeline strings. + +## OUTPUTS + +### PSTextMate.Core.HighlightedText + +Returns a `HighlightedText` object representing the rendered tokens and styling metadata. + +## NOTES + +If language cannot be inferred and `-Language` is not provided, the cmdlet defaults to `powershell` for string input. Use `-Language` to override detection. + +## RELATED LINKS + +See `Get-SupportedTextMate` to discover available language IDs and `Format-*` cmdlets for language-specific formatting. diff --git a/docs/en-us/Test-SupportedTextMate.md b/docs/en-us/Test-SupportedTextMate.md new file mode 100644 index 0000000..683349e --- /dev/null +++ b/docs/en-us/Test-SupportedTextMate.md @@ -0,0 +1,164 @@ +--- +document type: cmdlet +external help file: PSTextMate.dll-Help.xml +HelpUri: '' +Locale: en-US +Module Name: PSTextMate +ms.date: 02-17-2026 +PlatyPS schema version: 2024-05-01 +title: Test-SupportedTextMate +--- + +# Test-SupportedTextMate + +## SYNOPSIS + +Tests whether a language, extension, or file is supported by the module's TextMate grammars. Returns a boolean or diagnostic object indicating support. + +## SYNTAX + +### FileSet (Default) + +``` +Test-SupportedTextMate -File [] +``` + +### ExtensionSet + +``` +Test-SupportedTextMate -Extension [] +``` + +### LanguageSet + +``` +Test-SupportedTextMate -Language [] +``` + +## ALIASES + +This cmdlet has the following aliases, + None + +## DESCRIPTION + +Test-SupportedTextMate verifies support for TextMate languages and extensions. +Use the `-File` parameter to check a specific file path, `-Extension` to verify a file extension, or `-Language` to test a language identifier. +The cmdlet returns `true` or `false` + +## EXAMPLES + +### Example 1 + +Example: test a file path for support + +``` +Test-SupportedTextMate -File .\src\Program.cs +``` + +### Example 2 + +Example: test by extension + +``` +Test-SupportedTextMate -Extension .ps1 +``` + +### Example 3 + +Example: test by language identifier + +``` +Test-SupportedTextMate -Language powershell +``` + +## PARAMETERS + +### -Extension + +File extension to test (for example `.ps1`, `.cs`). +When used the cmdlet returns whether the module has a grammar associated with that extension. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: ExtensionSet + Position: Named + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -File + +Path to a file to test for grammar support. +The path is resolved and existence is validated before checking support. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: FileSet + Position: Named + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Language + +TextMate language ID to test (for example `powershell`, `csharp`, `markdown`). +Returns whether that language ID is supported. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: LanguageSet + Position: Named + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### bool + +Returns `bool` results for support checks. In error cases or file-not-found scenarios the cmdlet may write errors or diagnostic objects to the pipeline. + +## NOTES + +Use `Get-SupportedTextMate` to discover available language IDs and their extensions before calling this cmdlet. + +## RELATED LINKS + +See `Get-SupportedTextMate` and `Show-TextMate` for discovery and rendering workflows. diff --git a/harness.ps1 b/harness.ps1 new file mode 100644 index 0000000..0d28f04 --- /dev/null +++ b/harness.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh +param([switch]$Load) +$s = { + param([string]$Path, [switch]$LoadOnly) + $Parent = Split-Path $Path -Parent + Import-Module (Join-Path $Parent 'PwshSpectreConsole' 'output' 'PwshSpectreConsole.psd1') + Import-Module (Join-Path $Path 'output' 'PSTextMate.psd1') + if (-not $LoadOnly) { + Format-Markdown (Join-Path $Path 'tests' 'test-markdown.md') + } +} +if ($Load) { + . $s -LoadOnly -Path $PSScriptRoot +} else { + pwsh -nop -c $s -args $PSScriptRoot +} diff --git a/src/Cmdlets/DebugCmdlets.cs b/src/Cmdlets/DebugCmdlets.cs deleted file mode 100644 index 3b0558c..0000000 --- a/src/Cmdlets/DebugCmdlets.cs +++ /dev/null @@ -1,250 +0,0 @@ -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/FormatCSharp.cs b/src/Cmdlets/FormatCSharp.cs new file mode 100644 index 0000000..a950cc7 --- /dev/null +++ b/src/Cmdlets/FormatCSharp.cs @@ -0,0 +1,14 @@ +using System.Management.Automation; +using PSTextMate.Core; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for rendering C# input using TextMate syntax highlighting. +/// +[Cmdlet(VerbsCommon.Format, "CSharp")] +[Alias("fcs")] +[OutputType(typeof(HighlightedText))] +public sealed class FormatCSharpCmdlet : TextMateCmdletBase { + protected override string FixedToken => "csharp"; +} diff --git a/src/Cmdlets/FormatMarkdown.cs b/src/Cmdlets/FormatMarkdown.cs new file mode 100644 index 0000000..38fa96e --- /dev/null +++ b/src/Cmdlets/FormatMarkdown.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using PSTextMate.Core; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for rendering Markdown input using TextMate syntax highlighting. +/// +[Cmdlet(VerbsCommon.Format, "Markdown")] +[Alias("fmd")] +[OutputType(typeof(HighlightedText))] +public sealed class FormatMarkdownCmdlet : TextMateCmdletBase { + /// + /// When present, force the standard renderer even for Markdown grammars. + /// + [Parameter] + public SwitchParameter Alternate { get; set; } + + protected override string FixedToken => "markdown"; + protected override bool UsesMarkdownBaseDirectory => true; + protected override bool UseAlternate => Alternate.IsPresent; +} diff --git a/src/Cmdlets/FormatPowershell.cs b/src/Cmdlets/FormatPowershell.cs new file mode 100644 index 0000000..bcaa7c1 --- /dev/null +++ b/src/Cmdlets/FormatPowershell.cs @@ -0,0 +1,14 @@ +using System.Management.Automation; +using PSTextMate.Core; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for rendering PowerShell input using TextMate syntax highlighting. +/// +[Cmdlet(VerbsCommon.Format, "PowerShell")] +[Alias("fps")] +[OutputType(typeof(HighlightedText))] +public sealed class FormatPowerShellCmdlet : TextMateCmdletBase { + protected override string FixedToken => "powershell"; +} diff --git a/src/Cmdlets/GetSupportedTextMate.cs b/src/Cmdlets/GetSupportedTextMate.cs new file mode 100644 index 0000000..00c390e --- /dev/null +++ b/src/Cmdlets/GetSupportedTextMate.cs @@ -0,0 +1,17 @@ +using System.Management.Automation; +using TextMateSharp.Grammars; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for retrieving all supported TextMate languages and their configurations. +/// Returns detailed information about available grammars and extensions. +/// +[OutputType(typeof(Language))] +[Cmdlet(VerbsCommon.Get, "SupportedTextMate")] +public sealed class GetSupportedTextMateCmdlet : PSCmdlet { + /// + /// Finalizes processing and outputs all supported languages. + /// + protected override void EndProcessing() => WriteObject(TextMateHelper.AvailableLanguages, enumerateCollection: true); +} diff --git a/src/Cmdlets/ShowTextMateCmdlet.cs b/src/Cmdlets/ShowTextMateCmdlet.cs index e1d25c9..f1c32de 100644 --- a/src/Cmdlets/ShowTextMateCmdlet.cs +++ b/src/Cmdlets/ShowTextMateCmdlet.cs @@ -1,192 +1,59 @@ +using System.IO; using System.Management.Automation; +using PSTextMate.Core; +using PSTextMate.Utilities; using TextMateSharp.Grammars; -using PwshSpectreConsole.TextMate.Extensions; -using Spectre.Console; -using PwshSpectreConsole.TextMate; -namespace PwshSpectreConsole.TextMate.Cmdlets; +namespace PSTextMate.Commands; /// /// 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 = []; - private string? _sourceExtensionHint; - - [Parameter( - Mandatory = true, - ValueFromPipeline = true, - ParameterSetName = "String" - )] - [AllowEmptyString] - [ValidateNotNull] - public string? InputObject { get; set; } - - [Parameter( - Mandatory = true, - ValueFromPipelineByPropertyName = true, - ParameterSetName = "Path", - Position = 0 - )] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string? Path { get; set; } - - [Parameter( - ParameterSetName = "String" - )] - [Parameter( - ParameterSetName = "Path" - )] +[Cmdlet(VerbsCommon.Show, "TextMate", DefaultParameterSetName = "Default")] +[Alias("stm", "Show-Code")] +[OutputType(typeof(HighlightedText))] +public sealed class ShowTextMateCmdlet : TextMateCmdletBase { + /// + /// TextMate language ID for syntax highlighting (e.g., 'powershell', 'csharp', 'python'). + /// If not specified, detected from file extension (for files) or defaults to 'powershell' (for strings). + /// + [Parameter] [ArgumentCompleter(typeof(LanguageCompleter))] public string? Language { get; set; } - [Parameter()] - public ThemeName Theme { get; set; } = ThemeName.DarkPlus; - + /// + /// When present, force use of the standard renderer even for Markdown grammars. + /// This can be used to preview alternate rendering behavior. + /// [Parameter] - public SwitchParameter PassThru { get; set; } + public SwitchParameter Alternate { get; set; } - protected override void ProcessRecord() - { - if (ParameterSetName == "String" && InputObject is not null) - { - // Try to capture an extension hint from ETS note properties on the current pipeline object - // (e.g., PSChildName/PSPath added by Get-Content) - if (_sourceExtensionHint is null) - { - if (GetVariableValue("_") is PSObject current) - { - string? hint = current.Properties["PSChildName"]?.Value as string - ?? current.Properties["PSPath"]?.Value as string - ?? current.Properties["Path"]?.Value as string - ?? current.Properties["FullName"]?.Value as string; - if (!string.IsNullOrWhiteSpace(hint)) - { - string ext = System.IO.Path.GetExtension(hint); - if (!string.IsNullOrWhiteSpace(ext)) - { - _sourceExtensionHint = ext; - } - } - } - } - _inputObjectBuffer.Add(InputObject); - return; - } + protected override string FixedToken => string.Empty; - if (ParameterSetName == "Path" && !string.IsNullOrWhiteSpace(Path)) - { - try - { - Rows? result = ProcessPathInput(); - if (result is not null) - { - WriteObject(result); - if (PassThru) - { - WriteVerbose($"Processed file '{Path}' with theme '{Theme}' {(string.IsNullOrWhiteSpace(Language) ? "(by extension)" : $"(token: {Language})")}"); - } - } - } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, Path!)); - } - } - } - - protected override void EndProcessing() - { - // For Path parameter set, each record is processed in ProcessRecord to support streaming multiple files. - // Only finalize buffered String input here. - if (ParameterSetName != "String") - { - return; - } + protected override bool UsesMarkdownBaseDirectory => true; - try - { - Rows? result = ProcessStringInput(); - if (result is not null) - { - WriteObject(result); - if (PassThru) - { - WriteVerbose($"Processed {_inputObjectBuffer.Count} lines with theme '{Theme}' {(string.IsNullOrWhiteSpace(Language) ? "(by hint/default)" : $"(token: {Language})")}"); - } - } - } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "ShowTextMateCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); - } - } + protected override bool UsesExtensionHint => true; - private Rows? ProcessStringInput() - { - if (_inputObjectBuffer.Count == 0) - { - WriteVerbose("No input provided"); - return null; - } + protected override bool UseAlternate => Alternate.IsPresent; - string[] strings = [.. _inputObjectBuffer]; - // If only one string and it contains any newline, split it into lines for correct rendering - if (strings.Length == 1 && (strings[0].Contains('\n') || strings[0].Contains('\r'))) - { - strings = strings[0].Split(NewLineSplit, StringSplitOptions.None); - } - if (strings.AllIsNullOrEmpty()) - { - WriteVerbose("All input strings are null or empty"); - return null; - } + protected override string? DefaultLanguage => "powershell"; - // If a Language token was provided, resolve it first (language id or extension) - if (!string.IsNullOrWhiteSpace(Language)) - { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language!); - return Converter.ProcessLines(strings, Theme, token, isExtension: asExtension); - } + protected override (string token, bool asExtension) ResolveTokenForStringInput() { + string effectiveLanguage = !string.IsNullOrEmpty(Language) + ? Language + : !string.IsNullOrEmpty(SourceExtensionHint) + ? SourceExtensionHint + : DefaultLanguage ?? "powershell"; - // Otherwise prefer extension hint from ETS (PSChildName/PSPath) - if (!string.IsNullOrWhiteSpace(_sourceExtensionHint)) - { - return Converter.ProcessLines(strings, Theme, _sourceExtensionHint!, isExtension: true); - } + WriteVerbose($"effectiveLanguage: {effectiveLanguage}"); - // Final fallback: default language - return Converter.ProcessLines(strings, Theme, "powershell", isExtension: false); + return TextMateResolver.ResolveToken(effectiveLanguage); } - private Rows? ProcessPathInput() - { - FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(Path)); - - if (!filePath.Exists) - { - throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); - } - - // Decide how to interpret based on precedence: - // 1) Language token (can be a language id OR an extension) - // 2) File extension - string[] lines = File.ReadAllLines(filePath.FullName); - if (!string.IsNullOrWhiteSpace(Language)) - { - (string? token, bool asExtension) = TextMateResolver.ResolveToken(Language!); - WriteVerbose($"Processing file: {filePath.FullName} with explicit token: {Language} (as {(asExtension ? "extension" : "language")})"); - return Converter.ProcessLines(lines, Theme, token, isExtension: asExtension); - } - string extension = filePath.Extension; - WriteVerbose($"Processing file: {filePath.FullName} using file extension: {extension}"); - return Converter.ProcessLines(lines, Theme, extension, isExtension: true); + protected override (string token, bool asExtension) ResolveTokenForPathInput(FileInfo filePath) { + return !string.IsNullOrWhiteSpace(Language) + ? TextMateResolver.ResolveToken(Language) + : (filePath.Extension, true); } } diff --git a/src/Cmdlets/SupportCmdlets.cs b/src/Cmdlets/SupportCmdlets.cs deleted file mode 100644 index 6ee5f6e..0000000 --- a/src/Cmdlets/SupportCmdlets.cs +++ /dev/null @@ -1,51 +0,0 @@ -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/Cmdlets/TestSupportedTextMate.cs b/src/Cmdlets/TestSupportedTextMate.cs new file mode 100644 index 0000000..5a3cee3 --- /dev/null +++ b/src/Cmdlets/TestSupportedTextMate.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Management.Automation; +using TextMateSharp.Grammars; + +namespace PSTextMate.Commands; + +/// +/// Cmdlet for testing TextMate support for languages, extensions, and files. +/// Provides validation functionality to check compatibility before processing. +/// +[OutputType(typeof(bool))] +[Cmdlet(VerbsDiagnostic.Test, "SupportedTextMate", DefaultParameterSetName = "FileSet")] +public sealed class TestSupportedTextMateCmdlet : PSCmdlet { + /// + /// File extension to test for support (e.g., '.ps1'). + /// + [Parameter( + ParameterSetName = "ExtensionSet", + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string? Extension { get; set; } + + /// + /// Language ID to test for support (e.g., 'powershell'). + /// + [Parameter( + ParameterSetName = "LanguageSet", + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string? Language { get; set; } + + /// + /// File path to test for support. + /// + [Parameter( + ParameterSetName = "FileSet", + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string? File { get; set; } + + /// + /// Finalizes processing and outputs support check results. + /// + protected override void EndProcessing() { + switch (ParameterSetName) { + case "FileSet": + FileInfo filePath = new(GetUnresolvedProviderPathFromPSPath(File!)); + if (!filePath.Exists) { + var exception = new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); + WriteError(new ErrorRecord(exception, nameof(TestSupportedTextMateCmdlet), ErrorCategory.ObjectNotFound, filePath.FullName)); + return; + } + WriteObject(TextMateExtensions.IsSupportedFile(filePath.FullName)); + break; + case "ExtensionSet": + WriteObject(TextMateExtensions.IsSupportedExtension(Extension!)); + break; + case "LanguageSet": + WriteObject(TextMateLanguages.IsSupportedLanguage(Language!)); + break; + default: + break; + } + } +} diff --git a/src/Cmdlets/TextMateCmdletBase.cs b/src/Cmdlets/TextMateCmdletBase.cs new file mode 100644 index 0000000..e3c9a5e --- /dev/null +++ b/src/Cmdlets/TextMateCmdletBase.cs @@ -0,0 +1,243 @@ +using System.Management.Automation; +using PSTextMate; +using PSTextMate.Core; +using PSTextMate.Utilities; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; + +namespace PSTextMate.Commands; + +/// +/// Base cmdlet for rendering input using TextMate language or extension tokens. +/// +public abstract class TextMateCmdletBase : PSCmdlet { + private readonly List _inputObjectBuffer = []; + + /// + /// String content or file path to render with syntax highlighting. + /// + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, + Position = 0 + )] + [AllowEmptyString] + [AllowNull] + [Alias("FullName", "Path")] + public PSObject? InputObject { get; set; } + + /// + /// Color theme to use for syntax highlighting. + /// + [Parameter] + public ThemeName Theme { get; set; } = ThemeName.DarkPlus; + + /// + /// When present, render a gutter with line numbers. + /// + [Parameter] + public SwitchParameter LineNumbers { get; set; } + + + /// + /// Fixed language or extension token used for rendering. + /// + protected abstract string FixedToken { get; } + + /// + /// Indicates whether the fixed token should be treated as a file extension. + /// + protected virtual bool FixedTokenIsExtension => false; + + /// + /// Indicates whether a Markdown base directory should be resolved for image paths. + /// + protected virtual bool UsesMarkdownBaseDirectory => false; + + /// + /// Indicates whether an extension hint should be inferred from pipeline metadata. + /// + protected virtual bool UsesExtensionHint => false; + + /// + /// Error identifier used for error records. + /// + protected virtual string ErrorId => GetType().Name; + + /// + /// Indicates whether alternate rendering should be used. + /// + protected virtual bool UseAlternate => false; + + /// + /// Default language token used when no explicit input is available. + /// + protected virtual string? DefaultLanguage => null; + + /// + /// Resolved extension hint from the pipeline input. + /// + protected string? SourceExtensionHint { get; private set; } + + /// + /// Resolved base directory for markdown rendering. + /// + protected string? SourceBaseDirectory { get; private set; } + + protected override void ProcessRecord() { + if (MyInvocation.ExpectingInput) { + if (InputObject?.BaseObject is FileInfo file) { + try { + foreach (HighlightedText result in ProcessPathInput(file)) { + + WriteObject(result); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, ErrorId, ErrorCategory.NotSpecified, file)); + } + } + else if (InputObject?.BaseObject is string inputString) { + if (UsesMarkdownBaseDirectory || UsesExtensionHint) { + EnsureSourceHints(); + } + + _inputObjectBuffer.Add(inputString); + } + } + else if (InputObject is not null) { + FileInfo file = new(GetUnresolvedProviderPathFromPSPath(InputObject?.ToString())); + if (!file.Exists) { + return; + } + + try { + foreach (HighlightedText result in ProcessPathInput(file)) { + + WriteObject(result); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, ErrorId, ErrorCategory.NotSpecified, file)); + } + } + } + + protected override void EndProcessing() { + try { + if (_inputObjectBuffer.Count == 0) { + return; + } + + if (UsesMarkdownBaseDirectory || UsesExtensionHint) { + EnsureSourceHints(); + } + + HighlightedText? result = ProcessStringInput(); + if (result is not null) { + WriteObject(result); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, ErrorId, ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); + } + } + + private HighlightedText? ProcessStringInput() { + string[] lines = TextMateHelper.NormalizeToLines(_inputObjectBuffer); + + if (lines.AllIsNullOrEmpty()) { + WriteVerbose("All input strings are null or empty"); + return null; + } + + (string token, bool asExtension) = ResolveTokenForStringInput(); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: UseAlternate); + + return renderables is null + ? null + : new HighlightedText { + Renderables = renderables, + ShowLineNumbers = LineNumbers.IsPresent + }; + } + + private IEnumerable ProcessPathInput(FileInfo filePath) { + if (!filePath.Exists) { + throw new FileNotFoundException($"File not found: {filePath.FullName}", filePath.FullName); + } + + if (UsesMarkdownBaseDirectory) { + string markdownBaseDir = filePath.DirectoryName ?? Environment.CurrentDirectory; + Rendering.ImageRenderer.CurrentMarkdownDirectory = markdownBaseDir; + WriteVerbose($"Set markdown base directory for image resolution: {markdownBaseDir}"); + } + + (string token, bool asExtension) = ResolveTokenForPathInput(filePath); + WriteVerbose($"Processing file: {filePath.FullName} with {(asExtension ? "extension" : "language")}: {token}"); + + string[] lines = File.ReadAllLines(filePath.FullName); + IRenderable[]? renderables = TextMateProcessor.ProcessLines(lines, Theme, token, isExtension: asExtension, forceAlternate: UseAlternate); + + if (renderables is not null) { + yield return new HighlightedText { + Renderables = renderables, + ShowLineNumbers = LineNumbers.IsPresent + }; + } + } + + protected virtual (string token, bool asExtension) ResolveTokenForStringInput() => ResolveFixedToken(); + + protected virtual (string token, bool asExtension) ResolveTokenForPathInput(FileInfo filePath) => ResolveFixedToken(); + + protected (string token, bool asExtension) ResolveFixedToken() { + if (!FixedTokenIsExtension) { + return TextMateResolver.ResolveToken(FixedToken); + } + + string token = FixedToken.StartsWith('.') ? FixedToken : "." + FixedToken; + return (token, true); + } + + private void EnsureSourceHints() { + if (InputObject is null) { + return; + } + + if (SourceBaseDirectory is not null && SourceExtensionHint is not null) { + return; + } + + string? hint = InputObject.Properties["PSPath"]?.Value as string + ?? InputObject.Properties["FullName"]?.Value as string + ?? InputObject.Properties["PSChildName"]?.Value as string; + if (string.IsNullOrEmpty(hint)) { + return; + } + + if (SourceExtensionHint is null) { + string ext = Path.GetExtension(hint); + if (string.IsNullOrWhiteSpace(ext)) { + string resolvedHint = GetUnresolvedProviderPathFromPSPath(hint); + ext = Path.GetExtension(resolvedHint); + } + + if (!string.IsNullOrWhiteSpace(ext)) { + SourceExtensionHint = ext; + WriteVerbose($"Detected extension hint from input: {ext}"); + } + } + + if (SourceBaseDirectory is null) { + string resolvedPath = GetUnresolvedProviderPathFromPSPath(hint); + string? baseDir = Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrWhiteSpace(baseDir)) { + SourceBaseDirectory = baseDir; + Rendering.ImageRenderer.CurrentMarkdownDirectory = baseDir; + WriteVerbose($"Set markdown base directory from input: {baseDir}"); + } + } + } +} diff --git a/src/Compatibility/Converter.cs b/src/Compatibility/Converter.cs deleted file mode 100644 index 46c4035..0000000 --- a/src/Compatibility/Converter.cs +++ /dev/null @@ -1,13 +0,0 @@ -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/Infrastructure/CacheManager.cs b/src/Core/CacheManager.cs similarity index 85% rename from src/Infrastructure/CacheManager.cs rename to src/Core/CacheManager.cs index c27b9cf..e5587af 100644 --- a/src/Infrastructure/CacheManager.cs +++ b/src/Core/CacheManager.cs @@ -3,14 +3,13 @@ using TextMateSharp.Registry; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Infrastructure; +namespace PSTextMate.Core; /// /// Manages caching of expensive TextMate objects for improved performance. /// Uses thread-safe collections to handle concurrent access patterns. /// -internal static class CacheManager -{ +internal static class CacheManager { private static readonly ConcurrentDictionary _themeCache = new(); private static readonly ConcurrentDictionary _grammarCache = new(); @@ -20,10 +19,8 @@ internal static class CacheManager /// /// The theme to load /// Cached registry and theme pair - public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeName) - { - return _themeCache.GetOrAdd(themeName, name => - { + public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeName) { + return _themeCache.GetOrAdd(themeName, name => { RegistryOptions options = new(name); Registry registry = new(options); Theme theme = registry.GetTheme(); @@ -39,11 +36,9 @@ public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeNam /// Language ID or file extension /// True if grammarId is a file extension, false if it's a language ID /// Cached grammar instance or null if not found - public static IGrammar? GetCachedGrammar(Registry registry, string grammarId, bool isExtension) - { + public static IGrammar? GetCachedGrammar(Registry registry, string grammarId, bool isExtension) { string cacheKey = $"{grammarId}_{isExtension}"; - return _grammarCache.GetOrAdd(cacheKey, _ => - { + return _grammarCache.GetOrAdd(cacheKey, _ => { RegistryOptions options = new(ThemeName.Dark); // Use default for grammar loading return isExtension ? registry.LoadGrammar(options.GetScopeByExtension(grammarId)) @@ -54,8 +49,7 @@ public static (Registry registry, Theme theme) GetCachedTheme(ThemeName themeNam /// /// Clears all cached objects. Useful for memory management or when themes/grammars change. /// - public static void ClearCache() - { + public static void ClearCache() { _themeCache.Clear(); _grammarCache.Clear(); } diff --git a/src/Core/HighlightedText.cs b/src/Core/HighlightedText.cs new file mode 100644 index 0000000..0dc5c28 --- /dev/null +++ b/src/Core/HighlightedText.cs @@ -0,0 +1,188 @@ +using System.Globalization; +using System.Linq; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PSTextMate.Core; + +/// +/// Represents syntax-highlighted text ready for rendering. +/// Provides a clean, consistent output type. +/// Implements IRenderable so it can be used directly with Spectre.Console. +/// +public sealed class HighlightedText : Renderable { + /// + /// The highlighted renderables ready for display. + /// + public required IRenderable[] Renderables { get; init; } + + /// + /// When true, prepend line numbers with a gutter separator. + /// + public bool ShowLineNumbers { get; init; } + + /// + /// Starting line number for the gutter. + /// + public int LineNumberStart { get; init; } = 1; + + /// + /// Optional fixed width for the line number column. + /// + public int? LineNumberWidth { get; init; } + + /// + /// Separator inserted between the line number and content. + /// + public string GutterSeparator { get; init; } = " │ "; + + /// + /// Number of lines contained in this highlighted text. + /// + public int LineCount => Renderables.Length; + + /// + /// Renders the highlighted text by combining all renderables into a single output. + /// + protected override IEnumerable Render(RenderOptions options, int maxWidth) { + // Delegate to Rows which efficiently renders all renderables + var rows = new Rows(Renderables); + + return !ShowLineNumbers ? ((IRenderable)rows).Render(options, maxWidth) : RenderWithLineNumbers(rows, options, maxWidth); + } + + /// + /// Measures the dimensions of the highlighted text. + /// + protected override Measurement Measure(RenderOptions options, int maxWidth) { + // Delegate to Rows for measurement + var rows = new Rows(Renderables); + + return !ShowLineNumbers ? ((IRenderable)rows).Measure(options, maxWidth) : MeasureWithLineNumbers(rows, options, maxWidth); + } + + private IEnumerable RenderWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { + (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); + return PrefixLineNumbers(segments, options, width, contentWidth); + } + + private Measurement MeasureWithLineNumbers(Rows rows, RenderOptions options, int maxWidth) { + (List segments, int width, int contentWidth) = RenderInnerSegments(rows, options, maxWidth); + Measurement measurement = ((IRenderable)rows).Measure(options, contentWidth); + int gutterWidth = width + GutterSeparator.Length; + return new Measurement(measurement.Min + gutterWidth, measurement.Max + gutterWidth); + } + + private (List segments, int width, int contentWidth) RenderInnerSegments(Rows rows, RenderOptions options, int maxWidth) { + int width = ResolveLineNumberWidth(LineCount); + int contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); + var segments = ((IRenderable)rows).Render(options, contentWidth).ToList(); + + int actualLineCount = CountLines(segments); + int actualWidth = ResolveLineNumberWidth(actualLineCount); + if (actualWidth != width) { + width = actualWidth; + contentWidth = Math.Max(1, maxWidth - (width + GutterSeparator.Length)); + segments = [.. ((IRenderable)rows).Render(options, contentWidth)]; + } + + return (segments, width, contentWidth); + } + + private IEnumerable PrefixLineNumbers(List segments, RenderOptions options, int width, int contentWidth) { + int lineNumber = LineNumberStart; + + foreach (List line in SplitLines(segments)) { + string label = lineNumber.ToString(CultureInfo.InvariantCulture).PadLeft(width) + GutterSeparator; + foreach (Segment segment in ((IRenderable)new Text(label)).Render(options, contentWidth)) { + yield return segment; + } + + foreach (Segment segment in line) { + yield return segment; + } + + yield return Segment.LineBreak; + lineNumber++; + } + } + + private static IEnumerable> SplitLines(IEnumerable segments) { + List current = []; + bool sawLineBreak = false; + + foreach (Segment segment in segments) { + if (segment.IsLineBreak) { + yield return current; + current = []; + sawLineBreak = true; + continue; + } + + current.Add(segment); + } + + if (current.Count > 0 || !sawLineBreak) { + if (current.Count > 0) { + yield return current; + } + } + } + + private static int CountLines(List segments) { + if (segments.Count == 0) { + return 0; + } + + int lineBreaks = segments.Count(segment => segment.IsLineBreak); + return lineBreaks == 0 ? 1 : segments[^1].IsLineBreak ? lineBreaks : lineBreaks + 1; + } + + private int ResolveLineNumberWidth(int lineCount) { + if (LineNumberWidth.HasValue && LineNumberWidth.Value > 0) { + return LineNumberWidth.Value; + } + + int lastLineNumber = LineNumberStart + Math.Max(0, lineCount - 1); + return lastLineNumber.ToString(CultureInfo.InvariantCulture).Length; + } + + /// + /// Wraps the highlighted text in a Spectre.Console Panel. + /// + /// Optional panel title + /// Border style to use (default: Rounded) + /// Panel containing the highlighted text + public Panel ToPanel(string? title = null, BoxBorder? border = null) { + Panel panel = new(this); + + if (!string.IsNullOrEmpty(title)) { + panel.Header(title); + } + + if (border != null) { + panel.Border(border); + } + else { + panel.Border(BoxBorder.Rounded); + } + + return panel; + } + + // public override string ToString() => ToPanel(); + + /// + /// Wraps the highlighted text with padding. + /// + /// Padding to apply + /// Padder containing the highlighted text + public Padder WithPadding(Padding padding) => new(this, padding); + + /// + /// Wraps the highlighted text with uniform padding on all sides. + /// + /// Padding size for all sides + /// Padder containing the highlighted text + public Padder WithPadding(int size) => new(this, new Padding(size)); +} diff --git a/src/Core/MarkdigSpectreMarkdownRenderer.cs b/src/Core/MarkdigSpectreMarkdownRenderer.cs deleted file mode 100644 index 7a17047..0000000 --- a/src/Core/MarkdigSpectreMarkdownRenderer.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Spectre.Console; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// Legacy wrapper for the refactored markdown renderer. -/// Now uses the renderer that builds Spectre.Console objects directly. -/// This eliminates VT escaping issues and improves performance. -/// -internal static class MarkdigSpectreMarkdownRenderer -{ - /// - /// Renders markdown content using the Spectre.Console object building approach. - /// - /// Markdown text (can be multi-line) - /// Theme object for styling - /// Theme name for TextMateProcessor - /// Rows object for Spectre.Console rendering - public static Rows Render(string markdown, Theme theme, ThemeName themeName) - { - return Markdown.MarkdownRenderer.Render(markdown, theme, themeName); - } -} diff --git a/src/Core/MarkdigTextMateScopeMapper.cs b/src/Core/MarkdigTextMateScopeMapper.cs index f127a34..7cfe227 100644 --- a/src/Core/MarkdigTextMateScopeMapper.cs +++ b/src/Core/MarkdigTextMateScopeMapper.cs @@ -1,10 +1,9 @@ -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Maps Markdig markdown element types to TextMate scopes for theme lookup. /// -internal static class MarkdigTextMateScopeMapper -{ +internal static class MarkdigTextMateScopeMapper { private static readonly Dictionary BlockScopeMap = new() { { "Heading1", new[] { "markup.heading.1.markdown", "markup.heading.markdown" } }, @@ -38,29 +37,20 @@ internal static class MarkdigTextMateScopeMapper { "LineBreak", new[] { "text.whitespace" } }, }; - public static string[] GetBlockScopes(string blockType, int headingLevel = 0) - { - if (blockType == "Heading" && headingLevel > 0 && headingLevel <= 6) - return BlockScopeMap[$"Heading{headingLevel}"]; - if (BlockScopeMap.TryGetValue(blockType, out string[]? scopes)) - return scopes; - return ["text.plain"]; + public static string[] GetBlockScopes(string blockType, int headingLevel = 0) { + return blockType == "Heading" && headingLevel > 0 && headingLevel <= 6 + ? BlockScopeMap[$"Heading{headingLevel}"] + : BlockScopeMap.TryGetValue(blockType, out string[]? scopes) ? scopes : ["text.plain"]; } - public static string[] GetInlineScopes(string inlineType, int emphasisLevel = 0) - { - if (inlineType == "Emphasis") - { - return emphasisLevel switch - { + public static string[] GetInlineScopes(string inlineType, int emphasisLevel = 0) { + return inlineType == "Emphasis" + ? emphasisLevel switch { 1 => InlineScopeMap["EmphasisItalic"], 2 => InlineScopeMap["EmphasisBold"], 3 => InlineScopeMap["EmphasisBoldItalic"], _ => ["text.plain"] - }; - } - if (InlineScopeMap.TryGetValue(inlineType, out string[]? scopes)) - return scopes; - return ["text.plain"]; + } + : InlineScopeMap.TryGetValue(inlineType, out string[]? scopes) ? scopes : ["text.plain"]; } } diff --git a/src/Core/Markdown/InlineProcessor.cs b/src/Core/Markdown/InlineProcessor.cs deleted file mode 100644 index 2c16ed3..0000000 --- a/src/Core/Markdown/InlineProcessor.cs +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index caf3eb4..0000000 --- a/src/Core/Markdown/MarkdownRenderer.cs +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 55da49d..0000000 --- a/src/Core/Markdown/Optimizations/SpanOptimizedMarkdownProcessor.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System.Buffers; -using System.Text; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Optimizations; - -/// -/// Provides span-optimized operations for markdown validation and input processing. -/// Reduces allocations during text analysis and validation operations. -/// -internal static class SpanOptimizedMarkdownProcessor -{ - private static readonly SearchValues LineBreakChars = SearchValues.Create(['\r', '\n']); - private static readonly SearchValues WhitespaceChars = SearchValues.Create([' ', '\t', '\r', '\n']); - - /// - /// Counts lines in markdown text using span operations for better performance. - /// - /// Markdown text to analyze - /// Number of lines - public static int CountLinesOptimized(ReadOnlySpan markdown) - { - if (markdown.IsEmpty) return 0; - - int lineCount = 1; // Start with 1 for the first line - int index = 0; - - while ((index = markdown[index..].IndexOfAny(LineBreakChars)) >= 0) - { - // Handle CRLF as single line break - if (index < markdown.Length - 1 && - markdown[index] == '\r' && - markdown[index + 1] == '\n') - { - index += 2; - } - else - { - index++; - } - - lineCount++; - - if (index >= markdown.Length) break; - } - - return lineCount; - } - - /// - /// Splits markdown into lines using span operations and returns string array. - /// Optimized to minimize allocations during the splitting process. - /// - /// Markdown text to split - /// Array of line strings - public static string[] SplitIntoLinesOptimized(ReadOnlySpan markdown) - { - if (markdown.IsEmpty) return []; - - int lineCount = CountLinesOptimized(markdown); - string[]? lines = new string[lineCount]; - int lineIndex = 0; - int start = 0; - - for (int i = 0; i < markdown.Length; i++) - { - bool isLineBreak = markdown[i] is '\r' or '\n'; - - if (isLineBreak) - { - lines[lineIndex++] = markdown[start..i].ToString(); - - // Handle CRLF - if (i < markdown.Length - 1 && markdown[i] == '\r' && markdown[i + 1] == '\n') - i++; // Skip the \n in \r\n - - start = i + 1; - } - } - - // Add the last line if it doesn't end with a line break - if (start < markdown.Length && lineIndex < lines.Length) - lines[lineIndex] = markdown[start..].ToString(); - - return lines; - } - - /// - /// Finds the maximum line length using span operations. - /// - /// Markdown text to analyze - /// Maximum line length - public static int FindMaxLineLengthOptimized(ReadOnlySpan markdown) - { - if (markdown.IsEmpty) return 0; - - int maxLength = 0; - int currentLength = 0; - - foreach (char c in markdown) - { - if (c is '\r' or '\n') - { - maxLength = Math.Max(maxLength, currentLength); - currentLength = 0; - } - else - { - currentLength++; - } - } - - // Check the last line - return Math.Max(maxLength, currentLength); - } - - /// - /// Efficiently trims whitespace from multiple lines using spans. - /// - /// Array of line strings - /// Array of trimmed lines - public static string[] TrimLinesOptimized(string[] lines) - { - string[]? trimmedLines = new string[lines.Length]; - - for (int i = 0; i < lines.Length; i++) - { - if (string.IsNullOrEmpty(lines[i])) - { - trimmedLines[i] = string.Empty; - continue; - } - - ReadOnlySpan trimmed = lines[i].AsSpan().Trim(); - trimmedLines[i] = trimmed.Length == lines[i].Length ? lines[i] : trimmed.ToString(); - } - - return trimmedLines; - } - - /// - /// Joins lines back into markdown using span-optimized operations. - /// - /// Lines to join - /// Line ending to use (default: \n) - /// Joined markdown text - public static string JoinLinesOptimized(ReadOnlySpan lines, ReadOnlySpan lineEnding = default) - { - if (lines.IsEmpty) return string.Empty; - if (lines.Length == 1) return lines[0] ?? string.Empty; - - ReadOnlySpan ending = lineEnding.IsEmpty ? "\n".AsSpan() : lineEnding; - - // Calculate total capacity - int totalLength = (lines.Length - 1) * ending.Length; - foreach (string line in lines) - totalLength += line?.Length ?? 0; - - var builder = new StringBuilder(totalLength); - - for (int i = 0; i < lines.Length; i++) - { - if (i > 0) builder.Append(ending); - if (lines[i] is not null) - builder.Append(lines[i].AsSpan()); - } - - return builder.ToString(); - } - - /// - /// Removes empty lines efficiently using span operations. - /// - /// Lines to filter - /// Array with empty lines removed - public static string[] RemoveEmptyLinesOptimized(string[] lines) - { - // First pass: count non-empty lines - int nonEmptyCount = 0; - foreach (string line in lines) - { - if (!string.IsNullOrEmpty(line) && !line.AsSpan().Trim().IsEmpty) - nonEmptyCount++; - } - - if (nonEmptyCount == lines.Length) return lines; // No empty lines - if (nonEmptyCount == 0) return []; // All empty - - // Second pass: copy non-empty lines - string[]? result = new string[nonEmptyCount]; - int index = 0; - - foreach (string line in lines) - { - if (!string.IsNullOrEmpty(line) && !line.AsSpan().Trim().IsEmpty) - result[index++] = line; - } - - return result; - } - - /// - /// Counts specific characters in markdown using span operations. - /// - /// Markdown text to analyze - /// Character to count - /// Number of occurrences - public static int CountCharacterOptimized(ReadOnlySpan markdown, char targetChar) - { - if (markdown.IsEmpty) return 0; - - int count = 0; - int index = 0; - - while ((index = markdown[index..].IndexOf(targetChar)) >= 0) - { - count++; - index++; - if (index >= markdown.Length) break; - } - - return count; - } -} diff --git a/src/Core/Markdown/README.md b/src/Core/Markdown/README.md deleted file mode 100644 index d3fe0c8..0000000 --- a/src/Core/Markdown/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Markdown Renderer Architecture - -This document describes the refactored markdown rendering architecture that replaced the monolithic `MarkdigSpectreMarkdownRenderer` class. - -## Overview - -The markdown rendering functionality has been split into focused, single-responsibility components organized in the `Core/Markdown` folder structure for better maintainability and testing. - -## Folder Structure - -``` -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 deleted file mode 100644 index bcdc99f..0000000 --- a/src/Core/Markdown/Renderers/BlockRenderer.cs +++ /dev/null @@ -1,44 +0,0 @@ -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/HeadingRenderer.cs b/src/Core/Markdown/Renderers/HeadingRenderer.cs deleted file mode 100644 index 63eeb33..0000000 --- a/src/Core/Markdown/Renderers/HeadingRenderer.cs +++ /dev/null @@ -1,161 +0,0 @@ -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/ListRenderer.cs b/src/Core/Markdown/Renderers/ListRenderer.cs deleted file mode 100644 index f770481..0000000 --- a/src/Core/Markdown/Renderers/ListRenderer.cs +++ /dev/null @@ -1,266 +0,0 @@ -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 deleted file mode 100644 index 4e1a3c2..0000000 --- a/src/Core/Markdown/Renderers/ParagraphRenderer.cs +++ /dev/null @@ -1,408 +0,0 @@ -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 deleted file mode 100644 index c342587..0000000 --- a/src/Core/Markdown/Renderers/QuoteRenderer.cs +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 826770f..0000000 --- a/src/Core/Markdown/Renderers/TableRenderer.cs +++ /dev/null @@ -1,271 +0,0 @@ -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 deleted file mode 100644 index f61c933..0000000 --- a/src/Core/Markdown/Types/MarkdownTypes.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Spectre.Console.Rendering; -using TextMateSharp.Grammars; -using TextMateSharp.Themes; - -namespace PwshSpectreConsole.TextMate.Core.Markdown.Types; - -/// -/// Represents the result of rendering a markdown block element. -/// Provides type safety and better error handling for rendering operations. -/// -public sealed record MarkdownRenderResult -{ - /// - /// The rendered element that can be displayed by Spectre.Console. - /// - public IRenderable? Renderable { get; init; } - - /// - /// Indicates whether the rendering was successful. - /// - public bool Success { get; init; } - - /// - /// Error message if rendering failed. - /// - public string? ErrorMessage { get; init; } - - /// - /// The type of markdown block that was processed. - /// - public MarkdownBlockType BlockType { get; init; } - - /// - /// Creates a successful render result. - /// - public static MarkdownRenderResult CreateSuccess(IRenderable renderable, MarkdownBlockType blockType) => - new() { Renderable = renderable, Success = true, BlockType = blockType }; - - /// - /// Creates a failed render result. - /// - public static MarkdownRenderResult CreateFailure(string errorMessage, MarkdownBlockType blockType) => - new() { Success = false, ErrorMessage = errorMessage, BlockType = blockType }; - - /// - /// Creates a result for unsupported block types. - /// - public static MarkdownRenderResult CreateUnsupported(MarkdownBlockType blockType) => - new() { Success = false, ErrorMessage = $"Block type '{blockType}' is not supported", BlockType = blockType }; -} - -/// -/// Enumeration of supported markdown block types for better type safety. -/// -public enum MarkdownBlockType -{ - Unknown, - 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 deleted file mode 100644 index 937bce2..0000000 --- a/src/Core/MarkdownLinkFormatter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Spectre.Console; - -namespace PwshSpectreConsole.TextMate.Core; - -/// -/// Provides specialized formatting for Markdown elements. -/// Handles conversion of Markdown syntax to Spectre Console markup. -/// -internal static class MarkdownLinkFormatter -{ - /// - /// Creates a markdown link with Spectre Console markup. - /// - /// URL for the link - /// Display text for the link - /// Formatted link markup - public static string WriteMarkdownLink(string url, string linkText) - { - return $"[Blue link={url}]{linkText}[/] "; - } - - /// - /// Creates a markdown link with style information. - /// - /// URL for the link - /// Display text for the link - /// Tuple of formatted link and style - public static (string textEscaped, Style style) WriteMarkdownLinkWithStyle(string url, string linkText) - { - string mdlink = $"[link={url}]{Markup.Escape(linkText)}[/]"; - Style style = new(Color.Blue, Color.Default); - return (mdlink, style); - } -} diff --git a/src/Core/MarkdownRenderer.cs b/src/Core/MarkdownRenderer.cs index be7655b..f7f6e6f 100644 --- a/src/Core/MarkdownRenderer.cs +++ b/src/Core/MarkdownRenderer.cs @@ -1,131 +1,24 @@ -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; +namespace PSTextMate.Core; /// -/// Provides specialized rendering for Markdown content with enhanced link handling. -/// Includes special processing for Markdown links using Spectre Console link markup. +/// Facade for markdown rendering that adapts between TextMateProcessor's interface +/// and the Markdig-based renderer in PSTextMate.Rendering. /// -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()); - } - +internal static class MarkdownRenderer { /// - /// Processes markdown tokens with special handling for links. + /// Renders markdown content with compatibility layer for TextMateProcessor. /// - /// 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); - } + /// Markdown lines to render + /// Theme for syntax highlighting + /// Grammar (unused, maintained for interface compatibility) + /// Theme name enumeration + /// Rendered markdown as IRenderable array + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar, ThemeName themeName) { + string markdown = string.Join('\n', lines); + return Rendering.MarkdownRenderer.Render(markdown, theme, themeName); } } diff --git a/src/Core/MarkdownToken.cs b/src/Core/MarkdownToken.cs index 113569a..d5a5eca 100644 --- a/src/Core/MarkdownToken.cs +++ b/src/Core/MarkdownToken.cs @@ -1,20 +1,14 @@ using TextMateSharp.Grammars; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Simple token for theme lookup from a set of scopes (for markdown elements). /// -internal sealed class MarkdownToken : IToken -{ +internal sealed class MarkdownToken(IEnumerable scopes) : IToken { public string Text { get; set; } = string.Empty; public int StartIndex { get; set; } public int EndIndex { get; set; } public int Length { get; set; } - public List Scopes { get; } - - public MarkdownToken(IEnumerable scopes) - { - Scopes = [.. scopes]; - } + public List Scopes { get; } = [.. scopes]; } diff --git a/src/Core/StandardRenderer.cs b/src/Core/StandardRenderer.cs index 91b4cfc..c24e5f4 100644 --- a/src/Core/StandardRenderer.cs +++ b/src/Core/StandardRenderer.cs @@ -1,17 +1,17 @@ using System.Text; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Provides optimized rendering for standard (non-Markdown) TextMate grammars. /// Implements object pooling and batch processing for better performance. /// -internal static class StandardRenderer -{ +internal static class StandardRenderer { /// /// Renders text lines using standard TextMate grammar processing. /// Uses object pooling and batch processing for optimal performance. @@ -20,39 +20,33 @@ internal static class StandardRenderer /// Theme to apply /// Grammar for tokenization /// Rendered rows with syntax highlighting - public static Rows Render(string[] lines, Theme theme, IGrammar grammar) - { - return Render(lines, theme, grammar, null); - } + // public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) => Render(lines, theme, grammar); - public static Rows Render(string[] lines, Theme theme, IGrammar grammar, Action? debugCallback) - { - var builder = new StringBuilder(); + public static IRenderable[] Render(string[] lines, Theme theme, IGrammar grammar) { 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(); - } + try { + IStateStack? ruleStack = null; + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { + if (string.IsNullOrEmpty(lines[lineIndex])) { + rows.Add(new Rows(Text.Empty)); + continue; + } + var paragraph = new Paragraph(); + ITokenizeLineResult result = grammar.TokenizeLine(lines[lineIndex], ruleStack, TimeSpan.MaxValue); + ruleStack = result.RuleStack; + + TokenProcessor.ProcessTokensToParagraph(result.Tokens, lines[lineIndex], theme, paragraph); + rows.Add(new Rows(paragraph)); + } - return new Rows([.. rows]); + return [.. rows]; } - catch (ArgumentException ex) - { - throw new InvalidOperationException($"Argument error rendering content: {ex.Message}", ex); + catch (ArgumentException ex) { + throw new InvalidOperationException($"Argument error during rendering: {ex.Message}", ex); } - catch (Exception ex) - { - throw new InvalidOperationException($"Unexpected error rendering content: {ex.Message}", ex); + catch (Exception ex) { + throw new InvalidOperationException($"Unexpected error during rendering: {ex.Message}", ex); } } } diff --git a/src/Core/StyleHelper.cs b/src/Core/StyleHelper.cs index 4aac03d..2ff5602 100644 --- a/src/Core/StyleHelper.cs +++ b/src/Core/StyleHelper.cs @@ -1,14 +1,13 @@ using Spectre.Console; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Provides utility methods for style and color conversion operations. /// Handles conversion between TextMate and Spectre Console styling systems. /// -internal static class StyleHelper -{ +internal static class StyleHelper { /// /// Converts a theme color ID to a Spectre Console Color. /// @@ -16,21 +15,14 @@ internal static class StyleHelper /// 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)); - } + => colorId == -1 ? Color.Default : HexToColor(theme.GetColor(colorId)); /// /// Converts TextMate font style to Spectre Console decoration. /// /// TextMate font style /// Spectre Console decoration - public static Decoration GetDecoration(FontStyle fontStyle) - { + public static Decoration GetDecoration(FontStyle fontStyle) { Decoration result = Decoration.None; if (fontStyle == FontStyle.NotSet) return result; @@ -48,10 +40,8 @@ public static Decoration GetDecoration(FontStyle fontStyle) /// /// Hex color string (with or without #) /// Spectre Console Color instance - public static Color HexToColor(string hexString) - { - if (hexString.StartsWith('#')) - { + public static Color HexToColor(string hexString) { + if (hexString.StartsWith('#')) { hexString = hexString[1..]; } diff --git a/src/Core/TextMateProcessor.cs b/src/Core/TextMateProcessor.cs index 546d7c1..f5a8fd9 100644 --- a/src/Core/TextMateProcessor.cs +++ b/src/Core/TextMateProcessor.cs @@ -1,80 +1,55 @@ using System.Text; -using PwshSpectreConsole.TextMate.Infrastructure; -using PwshSpectreConsole.TextMate.Extensions; +using PSTextMate.Core; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Main entry point for TextMate processing operations. /// Provides high-performance text processing using TextMate grammars and themes. /// -public static class TextMateProcessor -{ +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. + /// 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 - /// 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) - { + /// Thrown when is null + /// Thrown when grammar cannot be found or processing encounters an error + public static IRenderable[]? ProcessLines(string[] lines, ThemeName themeName, string grammarId, bool isExtension, bool forceAlternate = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - if (lines.Length == 0 || lines.AllIsNullOrEmpty()) - { + 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 - { + 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); + // Resolve grammar using CacheManager which knows how to map language ids and extensions + IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension) ?? throw new InvalidOperationException(isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"); + + // if alternate it will use TextMate for markdown as well. + return grammar.GetName() == "Markdown" && forceAlternate + ? StandardRenderer.Render(lines, theme, grammar) + : (grammar.GetName() == "Markdown") + ? MarkdownRenderer.Render(lines, theme, grammar, themeName) + : StandardRenderer.Render(lines, theme, grammar); } - catch (InvalidOperationException) - { + catch (InvalidOperationException) { throw; } - catch (ArgumentException ex) - { + catch (ArgumentException ex) { throw new InvalidOperationException($"Argument error processing lines with grammar '{grammarId}': {ex.Message}", ex); } - catch (Exception ex) - { + catch (Exception ex) { throw new InvalidOperationException($"Unexpected error processing lines with grammar '{grammarId}': {ex.Message}", ex); } } @@ -88,17 +63,16 @@ public static class TextMateProcessor /// Language ID or file extension for grammar selection /// True if grammarId is a file extension, false if it's a language ID /// Rendered rows with syntax highlighting, or null if processing fails - public static Rows? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) - { + /// Thrown when is null + /// Thrown when grammar cannot be found or processing encounters an error + public static IRenderable[]? ProcessLinesCodeBlock(string[] lines, ThemeName themeName, string grammarId, bool isExtension = false) { ArgumentNullException.ThrowIfNull(lines, nameof(lines)); - try - { + try { (TextMateSharp.Registry.Registry registry, Theme theme) = CacheManager.GetCachedTheme(themeName); IGrammar? grammar = CacheManager.GetCachedGrammar(registry, grammarId, isExtension); - if (grammar is null) - { + if (grammar is null) { string errorMessage = isExtension ? $"Grammar not found for file extension: {grammarId}" : $"Grammar not found for language: {grammarId}"; @@ -108,16 +82,13 @@ public static class TextMateProcessor // Always use StandardRenderer for code blocks, never MarkdownRenderer return RenderCodeBlock(lines, theme, grammar); } - catch (InvalidOperationException) - { + catch (InvalidOperationException) { throw; } - catch (ArgumentException ex) - { + catch (ArgumentException ex) { throw new InvalidOperationException($"Argument error processing code block with grammar '{grammarId}': {ex.Message}", ex); } - catch (Exception ex) - { + catch (Exception ex) { throw new InvalidOperationException($"Unexpected error processing code block with grammar '{grammarId}': {ex.Message}", ex); } } @@ -125,23 +96,21 @@ public static class TextMateProcessor /// /// Renders code block lines without escaping markup characters. /// - private static Rows RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) - { - var builder = new StringBuilder(); + private static IRenderable[] RenderCodeBlock(string[] lines, Theme theme, IGrammar grammar) { 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); + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) { + if (string.IsNullOrEmpty(lines[lineIndex])) { + rows.Add(new Rows(Text.Empty)); + continue; + } + var paragraph = new Paragraph(); + ITokenizeLineResult result = grammar.TokenizeLine(lines[lineIndex], 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(); + TokenProcessor.ProcessTokensToParagraph(result.Tokens, lines[lineIndex], theme, paragraph); + rows.Add(new Rows(paragraph)); } - - return new Rows(rows.ToArray()); + return [.. rows]; } + } diff --git a/src/Core/TokenDebugInfo.cs b/src/Core/TokenDebugInfo.cs deleted file mode 100644 index 9b1cc4c..0000000 --- a/src/Core/TokenDebugInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -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 index bf9ad1c..8427a00 100644 --- a/src/Core/TokenProcessor.cs +++ b/src/Core/TokenProcessor.cs @@ -1,148 +1,205 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Text; +using PSTextMate.Utilities; using Spectre.Console; using TextMateSharp.Grammars; using TextMateSharp.Themes; -using PwshSpectreConsole.TextMate.Extensions; -namespace PwshSpectreConsole.TextMate.Core; +namespace PSTextMate.Core; /// /// Provides optimized token processing and styling operations. /// Handles theme property extraction and token rendering with performance optimizations. /// -internal static class TokenProcessor -{ +internal static class TokenProcessor { + private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), (int fg, int bg, FontStyle fs)> _themePropertyCache = new(); + // Cache Style results per (scopesKey, themeInstanceHash) + private static readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> _styleCache = new(); + + public static (int foreground, int background, FontStyle fontStyle) ExtractThemeProperties(IToken token, Theme theme) { + // Build a compact key from token scopes (they're mostly immutable per token) + string scopesKey = string.Join('\u001F', token.Scopes); + int themeHash = RuntimeHelpers.GetHashCode(theme); + (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); + + if (_themePropertyCache.TryGetValue(cacheKey, out (int fg, int bg, FontStyle fs) cached)) { + return (cached.fg, cached.bg, cached.fs); + } + + int foreground = -1; + int background = -1; + FontStyle fontStyle = FontStyle.NotSet; + + 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; + } + + // Store in cache even if defaults (-1) for future lookups + (int foreground, int background, FontStyle fontStyle) result = (foreground, background, fontStyle); + _themePropertyCache.TryAdd(cacheKey, result); + return result; + } + /// - /// Processes tokens in batches for better cache locality and performance. + /// Returns processed text and Style for the provided token span. This is the non-allocating + /// replacement for the original API callers previously relied on. It preserves the behavior + /// where the caller appends via AppendWithStyle so that Markup escaping and concatenation + /// semantics remain identical. /// - /// Tokens to process - /// Source line text - /// Theme for styling - /// StringBuilder for output - public static void ProcessTokensBatch( - IToken[] tokens, - string line, + public static (string processedText, Style? style) WriteTokenReturn( + ReadOnlySpan text, + Style? styleHint, Theme theme, + bool escapeMarkup = true) { + string processedText = escapeMarkup ? Markup.Escape(text.ToString()) : text.ToString(); + + // Early return for no styling needed + if (styleHint is null) { + return (processedText, null); + } + + // If the style serializes to an empty markup string, treat it as no style + // to avoid emitting empty [] tags which Spectre.Markup rejects. + string styleMarkup = styleHint.ToMarkup(); + if (string.IsNullOrEmpty(styleMarkup)) { + return (processedText, null); + } + + // Otherwise return the style as resolved + return (processedText, styleHint); + } + + /// + /// Append the provided text span into the builder with optional style and optional markup escaping. + /// (Existing fast-path writer retained for specialized callers.) + /// + public static void WriteToken( 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); + ReadOnlySpan text, + Style? style, + Theme theme, + bool escapeMarkup = true) { + // Fast-path: if no escaping needed, append span directly with style-aware overload + if (!escapeMarkup) { + if (style is not null) { + string styleMarkup = style.ToMarkup(); + if (!string.IsNullOrEmpty(styleMarkup)) { + builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); + } + else { + builder.Append(text).AppendLine(); + } + } + else { + builder.Append(text).AppendLine(); + } + return; + } - if (startIndex >= endIndex) continue; + // Check for presence of characters that require escaping. Most common tokens do not contain '[' or ']' + bool needsEscape = false; + foreach (char c in text) { + if (c is '[' or ']') { + needsEscape = true; + break; + } + } + + if (!needsEscape) { + // Safe fast-path: append span directly + if (style is not null) { + string styleMarkup = style.ToMarkup(); + if (!string.IsNullOrEmpty(styleMarkup)) { + builder.Append('[').Append(styleMarkup).Append(']').Append(text).Append("[/]").AppendLine(); + } + else { + builder.Append(text).AppendLine(); + } + } + else { + builder.Append(text).AppendLine(); + } + return; + } - 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() - }); + // Slow path: fallback to the reliable Markup.Escape for correctness when special characters are present + string escaped = Markup.Escape(text.ToString()); + if (style is not null) { + string styleMarkup = style.ToMarkup(); + if (!string.IsNullOrEmpty(styleMarkup)) { + builder.Append('[').Append(styleMarkup).Append(']').Append(escaped).Append("[/]").AppendLine(); + } + else { + builder.Append(escaped).AppendLine(); + } + } + else { + builder.Append(escaped).AppendLine(); } } /// - /// Processes tokens from TextMate grammar tokenization without escaping markup. - /// Used for code blocks where we want to preserve raw content. + /// Processes tokens and appends their text into the provided Paragraph using Spectre styles. + /// This avoids building markup strings and lets Spectre handle rendering directly. /// - /// 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( + public static void ProcessTokensToParagraph( IToken[] tokens, string line, Theme theme, - StringBuilder builder, - Action? debugCallback = null, - int? lineIndex = null) - { - foreach (IToken token in tokens) - { + Paragraph paragraph, + bool escapeMarkup = true) { + + foreach (IToken token in tokens) { int startIndex = Math.Min(token.StartIndex, line.Length); int endIndex = Math.Min(token.EndIndex, line.Length); - if (startIndex >= endIndex) continue; - 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() - }); + string text = line[startIndex..endIndex]; + Style? style = GetStyleForScopes(token.Scopes, theme); + + // Paragraph.Append does not interpret Spectre markup, so no escaping is necessary. + if (style is not null) { + paragraph.Append(text, style); + } + else { + paragraph.Append(text, Style.Plain); + } } } + /// + /// Returns a cached Style for the given scopes and theme. Returns null for default/no-style. + /// + public static Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { + string scopesKey = string.Join('\u001F', scopes); + int themeHash = RuntimeHelpers.GetHashCode(theme); + (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); - 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; + if (_styleCache.TryGetValue(cacheKey, out Style? cached)) { + return cached; } - 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); + // Fallback to extracting properties and building a Style + // Create a dummy token-like enumerable for existing ExtractThemeProperties method + var token = new MarkdownToken([.. scopes]); + (int fg, int bg, FontStyle fs) = ExtractThemeProperties(token, theme); + if (fg == -1 && bg == -1 && fs == FontStyle.NotSet) { + _styleCache.TryAdd(cacheKey, null); + return null; } - Decoration decoration = StyleHelper.GetDecoration(fontStyle); - Color backgroundColor = StyleHelper.GetColor(background, theme); - Color foregroundColor = StyleHelper.GetColor(foreground, theme); - Style style = new(foregroundColor, backgroundColor, decoration); + Color? foregroundColor = fg != -1 ? StyleHelper.GetColor(fg, theme) : null; + Color? backgroundColor = bg != -1 ? StyleHelper.GetColor(bg, theme) : null; + Decoration decoration = StyleHelper.GetDecoration(fs); - return (processedText, style); + var style = new Style(foregroundColor, backgroundColor, decoration); + _styleCache.TryAdd(cacheKey, style); + return style; } } diff --git a/src/Core/Validation/MarkdownInputValidator.cs b/src/Core/Validation/MarkdownInputValidator.cs deleted file mode 100644 index 09a1060..0000000 --- a/src/Core/Validation/MarkdownInputValidator.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Core.Validation; - -/// -/// Provides validation utilities for markdown input and rendering parameters. -/// Helps prevent security issues and improves error handling. -/// -internal static partial class MarkdownInputValidator -{ - private const int MaxMarkdownLength = 1_000_000; // 1MB text limit - private const int MaxLineCount = 10_000; - private const int MaxLineLength = 50_000; - - [GeneratedRegex(@")<[^<]*)*<\/script>", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex ScriptTagRegex(); - - [GeneratedRegex(@"javascript:|data:|vbscript:", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex DangerousUrlRegex(); - - /// - /// Validates markdown input for security and size constraints. - /// - /// The markdown text to validate - /// Validation result with any errors - public static ValidationResult ValidateMarkdownInput(string? markdown) - { - if (string.IsNullOrEmpty(markdown)) - return ValidationResult.Success!; - - var errors = new List(); - - // Check size limits - if (markdown.Length > MaxMarkdownLength) - errors.Add($"Markdown content exceeds maximum length of {MaxMarkdownLength:N0} characters"); - - string[] lines = markdown.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - if (lines.Length > MaxLineCount) - errors.Add($"Markdown content exceeds maximum line count of {MaxLineCount:N0}"); - - foreach (string line in lines) - { - if (line.Length > MaxLineLength) - { - errors.Add($"Line exceeds maximum length of {MaxLineLength:N0} characters"); - break; - } - } - - // Check for potentially dangerous content - if (ScriptTagRegex().IsMatch(markdown)) - errors.Add("Markdown contains potentially dangerous script tags"); - - // Check for dangerous URLs in links - if (DangerousUrlRegex().IsMatch(markdown)) - errors.Add("Markdown contains potentially dangerous URLs"); - - return errors.Count > 0 - ? new ValidationResult(string.Join("; ", errors)) - : ValidationResult.Success!; - } - - /// - /// Validates theme name parameter. - /// - /// The theme name to validate - /// True if valid, false otherwise - public static bool IsValidThemeName(ThemeName themeName) - { - 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/StringBuilderExtensions.cs b/src/Extensions/StringBuilderExtensions.cs deleted file mode 100644 index be023d7..0000000 --- a/src/Extensions/StringBuilderExtensions.cs +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index eef9374..0000000 --- a/src/Extensions/StringExtensions.cs +++ /dev/null @@ -1,51 +0,0 @@ -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/Helpers/Completers.cs b/src/Helpers/Completers.cs deleted file mode 100644 index 9b2f66a..0000000 --- a/src/Helpers/Completers.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Management.Automation; -using System.Management.Automation.Language; -using System.Text.RegularExpressions; - -namespace PwshSpectreConsole.TextMate; - -public sealed class LanguageCompleter : IArgumentCompleter -{ - /// - /// Offers completion for both TextMate language ids and file extensions. - /// Examples: "powershell", "csharp", ".md", "md", ".ps1", "ps1". - /// - public IEnumerable CompleteArgument( - string commandName, - string parameterName, - string wordToComplete, - CommandAst commandAst, - IDictionary fakeBoundParameters) - { - string input = wordToComplete ?? string.Empty; - bool wantsExtensionsOnly = input.Length > 0 && input[0] == '.'; - - // Prefer wildcard matching semantics; fall back to prefix/contains when empty - WildcardPattern? pattern = null; - if (!string.IsNullOrEmpty(input)) - { - // Add trailing * if not already present to make incremental typing friendly - string normalized = input[^1] == '*' ? input : input + "*"; - pattern = new WildcardPattern(normalized, WildcardOptions.IgnoreCase); - } - - bool Match(string token) - { - if (pattern is null) return true; // no filter - if (pattern.IsMatch(token)) return true; - // Also test without a leading dot to match bare extensions like "ps1" against ".ps1" - return token.StartsWith('.') && pattern.IsMatch(token[1..]); - } - - // Build suggestions - var results = new List(); - - if (!wantsExtensionsOnly) - { - // Languages first - foreach (string lang in TextMateHelper.Languages ?? Array.Empty()) - { - if (!Match(lang)) continue; - results.Add(new CompletionResult( - completionText: lang, - listItemText: lang, - resultType: CompletionResultType.ParameterValue, - toolTip: "TextMate language")); - } - } - - // Extensions (always include if requested or no leading '.') - foreach (string ext in TextMateHelper.Extensions ?? Array.Empty()) - { - if (!Match(ext)) continue; - string completion = ext; // keep dot in completion - string display = ext; - results.Add(new CompletionResult( - completionText: completion, - listItemText: display, - resultType: CompletionResultType.ParameterValue, - toolTip: "File extension")); - } - - // De-duplicate (in case of overlaps) and sort: languages first, then extensions, each alphabetically - return results - .GroupBy(r => r.CompletionText, StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .OrderByDescending(r => r.ToolTip.Equals("TextMate language", StringComparison.Ordinal)) - .ThenBy(r => r.CompletionText, StringComparer.OrdinalIgnoreCase); - } -} -public class TextMateLanguages : IValidateSetValuesGenerator - { - public string[] GetValidValues() - { - return TextMateHelper.Languages; - } - public static bool IsSupportedLanguage(string language) - { - return TextMateHelper.Languages.Contains(language); - } - } -public class TextMateExtensions : IValidateSetValuesGenerator -{ - public string[] GetValidValues() - { - return TextMateHelper.Extensions; - } - public static bool IsSupportedExtension(string extension) - { - return TextMateHelper.Extensions?.Contains(extension) == true; - } - public static bool IsSupportedFile(string file) - { - string ext = Path.GetExtension(file); - return TextMateHelper.Extensions?.Contains(ext) == true; - } - -} -public class TextMateExtensionTransform : ArgumentTransformationAttribute -{ - public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) - { - if (inputData is string input) - { - return input.StartsWith('.') ? input : '.' + input; - } - throw new ArgumentException("Input must be a string representing a file extension., '.ext' format expected.", nameof(inputData)); - } - -} diff --git a/src/Helpers/Debug.cs b/src/Helpers/Debug.cs deleted file mode 100644 index 3ab0553..0000000 --- a/src/Helpers/Debug.cs +++ /dev/null @@ -1,64 +0,0 @@ - -using TextMateSharp.Grammars; -using TextMateSharp.Themes; -using Spectre.Console; -using System.Collections.ObjectModel; - -// this is just for debugging purposes. - -namespace PwshSpectreConsole.TextMate; - -public static class Test -{ - public class TextMateDebug - { - 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; } - } - - public static TextMateDebug[]? DebugTextMate(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(new TextMateDebug - { - LineIndex = info.LineIndex, - StartIndex = info.StartIndex, - EndIndex = info.EndIndex, - Text = info.Text, - Scopes = info.Scopes, - Foreground = info.Foreground, - Background = info.Background, - FontStyle = info.FontStyle, - Style = info.Style, - Theme = info.Theme - }) - ); - return debugList.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 deleted file mode 100644 index 9b4c51f..0000000 --- a/src/Helpers/Helpers.cs +++ /dev/null @@ -1,31 +0,0 @@ -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate; - -public static class TextMateHelper -{ - public static readonly string[] Extensions; - public static readonly string[] Languages; - public static readonly List AvailableLanguages; - static TextMateHelper() - { - try - { - RegistryOptions _registryOptions = new(ThemeName.DarkPlus); - AvailableLanguages = _registryOptions.GetAvailableLanguages(); - - // 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/VTConversion.cs b/src/Helpers/VTConversion.cs deleted file mode 100644 index 863ace8..0000000 --- a/src/Helpers/VTConversion.cs +++ /dev/null @@ -1,616 +0,0 @@ -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/PSTextMate.csproj b/src/PSTextMate.csproj index e4642d1..fa3cc59 100644 --- a/src/PSTextMate.csproj +++ b/src/PSTextMate.csproj @@ -1,35 +1,40 @@ - - PSTextMate - net8.0 - enable - 0.1.0 - enable - 13.0 - Recommended - true - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - + + PSTextMate + net8.0 + enable + 0.1.0 + enable + 13.0 + + + + + + true + true + latest-Recommended + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/src/Rendering/BlockRenderer.cs b/src/Rendering/BlockRenderer.cs new file mode 100644 index 0000000..f47090f --- /dev/null +++ b/src/Rendering/BlockRenderer.cs @@ -0,0 +1,115 @@ +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// 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 + /// Enumerable of rendered items (each block produces one or more renderables) + public static IEnumerable RenderBlock(Block block, Theme theme, ThemeName themeName) { + return block switch { + // Special handling for paragraphs that contain only an image + // Return the image renderable followed by an explicit blank row so the image + // and the safety padding are separate renderables (not inside the same widget). + ParagraphBlock paragraph when MarkdownPatterns.IsStandaloneImage(paragraph) => + RenderStandaloneImage(paragraph, theme) is IRenderable r ? new[] { r, Text.NewLine } : [], + + // Use renderers that build Spectre.Console objects directly + HeadingBlock heading + => [HeadingRenderer.Render(heading, theme)], + ParagraphBlock paragraph + => ParagraphRenderer.Render(paragraph, theme), // Returns IEnumerable + ListBlock list + => ListRenderer.Render(list, theme), + Markdig.Extensions.Tables.Table table + => TableRenderer.Render(table, theme) is IRenderable t ? [t] : [], + FencedCodeBlock fencedCode + => CodeBlockRenderer.RenderFencedCodeBlock(fencedCode, theme, themeName) is IRenderable fc ? [fc] : [], + CodeBlock indentedCode + => CodeBlockRenderer.RenderCodeBlock(indentedCode, theme) is IRenderable ic ? [ic] : [], + + // Keep existing renderers for remaining complex blocks + QuoteBlock quote + => [QuoteRenderer.Render(quote, theme)], + HtmlBlock html + => HtmlBlockRenderer.Render(html, theme, themeName) is IRenderable h ? [h] : [], + ThematicBreakBlock + => [HorizontalRuleRenderer.Render()], + + // Unsupported block types + _ => [] + }; + } + + /// + /// Renders a standalone image (paragraph containing only an image). + /// Demonstrates how SixelImage can be directly rendered or wrapped in containers. + /// + private static IRenderable? RenderStandaloneImage(ParagraphBlock paragraph, Theme theme) { + if (paragraph.Inline is null) { + return null; + } + + // Find the image link + LinkInline? imageLink = paragraph.Inline + .OfType() + .FirstOrDefault(link => link.IsImage); + + if (imageLink is null) { + return null; + } + + // Extract alt text + string altText = ExtractImageAltText(imageLink); + + // Render using ImageBlockRenderer which handles various layouts + // Can render as: Direct (most common), PanelWithCaption, WithPadding, etc. + // This demonstrates how SixelImage (an IRenderable) can be embedded in different containers: + // - Panel: Wrap with border and title + // - Columns: Side-by-side layout + // - Rows: Vertical stacking + // - Grid: Flexible grid layout + // - Table: Inside table cells + // - Or rendered directly without wrapper + + return ImageBlockRenderer.RenderImageBlock( + altText, + imageLink.Url ?? "", + renderMode: ImageRenderMode.Direct); // Direct rendering is most efficient + } + + /// + /// Extracts alt text from an image link inline. + /// + private static string ExtractImageAltText(LinkInline imageLink) { + var textBuilder = new System.Text.StringBuilder(); + + foreach (Inline inline in imageLink) { + if (inline is LiteralInline literal) { + textBuilder.Append(literal.Content.ToString()); + } + else if (inline is CodeInline code) { + textBuilder.Append(code.Content); + } + } + + string result = textBuilder.ToString(); + return string.IsNullOrEmpty(result) ? "Image" : result; + } +} diff --git a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs b/src/Rendering/CodeBlockRenderer.cs similarity index 76% rename from src/Core/Markdown/Renderers/CodeBlockRenderer.cs rename to src/Rendering/CodeBlockRenderer.cs index d3e190c..44a2df9 100644 --- a/src/Core/Markdown/Renderers/CodeBlockRenderer.cs +++ b/src/Rendering/CodeBlockRenderer.cs @@ -1,21 +1,21 @@ -using System.Buffers; +using System.Buffers; using System.Text; +using Markdig.Helpers; using Markdig.Syntax; +using PSTextMate.Core; +using PSTextMate.Utilities; 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; +namespace PSTextMate.Rendering; /// /// Code block renderer that builds Spectre.Console objects directly /// and fixes whitespace and detection issues. /// -internal static class CodeBlockRenderer -{ +internal static class CodeBlockRenderer { // Cached SearchValues for improved performance private static readonly SearchValues LanguageDelimiters = SearchValues.Create([' ', '\t', '{', '}', '(', ')', '[', ']']); @@ -26,57 +26,48 @@ internal static class CodeBlockRenderer /// Theme for styling /// Theme name for TextMateProcessor /// Rendered code block in a panel - public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Theme theme, ThemeName themeName) - { + public static IRenderable RenderFencedCodeBlock(FencedCodeBlock fencedCode, Theme theme, ThemeName themeName) { string[] codeLines = ExtractCodeLinesWithWhitespaceHandling(fencedCode.Lines); - string language = ExtractLanguageImproved(fencedCode.Info); - - if (!string.IsNullOrEmpty(language)) - { - try - { - Rows? rows = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); - if (rows is not null) - { - return new Panel(rows) + string language = ExtractLanguage(fencedCode.Info); + + if (!string.IsNullOrEmpty(language)) { + try { + IRenderable[]? renderables = TextMateProcessor.ProcessLinesCodeBlock(codeLines, themeName, language, false); + if (renderables is not null) { + return new Panel(new Rows(renderables)) .Border(BoxBorder.Rounded) .Header(language, Justify.Left); } } - catch - { + catch { // Fallback to plain rendering } } // Fallback: create Text object directly instead of markup strings - return CreateOptimizedCodePanel(codeLines, language, theme); + return CreateCodePanel(codeLines, language, theme); } /// - /// Renders an indented code block with proper whitespace handling. - /// - /// The code block to render - /// Theme for styling - /// Rendered code block in a panel - public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) - { + /// Renders an indented code block with proper whitespace handling. + /// + /// The code block to render + /// Theme for styling + /// Rendered code block in a panel + public static IRenderable RenderCodeBlock(CodeBlock code, Theme theme) { string[] codeLines = ExtractCodeLinesFromStringLineGroup(code.Lines); - return CreateOptimizedCodePanel(codeLines, "code", theme); + return CreateCodePanel(codeLines, "code", theme); } /// /// Extracts code lines with simple and safe processing to avoid bounds issues. /// - private static string[] ExtractCodeLinesWithWhitespaceHandling(Markdig.Helpers.StringLineGroup lines) - { + private static string[] ExtractCodeLinesWithWhitespaceHandling(StringLineGroup lines) { if (lines.Count == 0) return []; var codeLines = new List(lines.Count); - foreach (StringLine line in lines.Lines) - { - try - { + foreach (StringLine line in lines.Lines) { + try { // Use the safest approach: let the slice handle its own bounds string lineText = line.Slice.ToString(); @@ -85,20 +76,20 @@ private static string[] ExtractCodeLinesWithWhitespaceHandling(Markdig.Helpers.S codeLines.Add(lineText); } - catch - { + catch { // If any error occurs, just use empty line codeLines.Add(string.Empty); } } // Convert to array and remove trailing empty lines - return RemoveTrailingEmptyLines(codeLines.ToArray()); - } /// + return RemoveTrailingEmptyLines([.. codeLines]); + } + + /// /// Extracts code lines from a string line group (for indented code blocks). /// - private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.StringLineGroup lines) - { + private static string[] ExtractCodeLinesFromStringLineGroup(StringLineGroup lines) { if (lines.Count == 0) return []; @@ -110,8 +101,7 @@ private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.Stri string[] splitLines = content.Split(['\r', '\n'], StringSplitOptions.None); // Process each line to handle whitespace correctly - for (int i = 0; i < splitLines.Length; i++) - { + for (int i = 0; i < splitLines.Length; i++) { splitLines[i] = TrimTrailingWhitespace(splitLines[i].AsSpan()).ToString(); } @@ -121,8 +111,7 @@ private static string[] ExtractCodeLinesFromStringLineGroup(Markdig.Helpers.Stri /// /// Improved language extraction with better detection patterns. /// - private static string ExtractLanguageImproved(string? info) - { + private static string ExtractLanguage(string? info) { if (string.IsNullOrWhiteSpace(info)) return string.Empty; @@ -133,8 +122,7 @@ private static string ExtractLanguageImproved(string? info) // Find first whitespace or special character to extract just the language int endIndex = infoSpan.IndexOfAny(LanguageDelimiters); - if (endIndex >= 0) - { + if (endIndex >= 0) { infoSpan = infoSpan[..endIndex]; } @@ -147,10 +135,8 @@ private static string ExtractLanguageImproved(string? info) /// /// Normalizes language names to improve code block detection. /// - private static string NormalizeLanguageName(string language) - { - return language switch - { + private static string NormalizeLanguageName(string language) { + return language switch { "c#" or "csharp" or "cs" => "csharp", "js" or "javascript" => "javascript", "ts" or "typescript" => "typescript", @@ -172,11 +158,9 @@ private static string NormalizeLanguageName(string language) /// /// Trims only trailing whitespace while preserving leading whitespace for indentation. /// - private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line) - { + private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line) { int end = line.Length; - while (end > 0 && char.IsWhiteSpace(line[end - 1])) - { + while (end > 0 && char.IsWhiteSpace(line[end - 1])) { end--; } return line[..end]; @@ -185,16 +169,14 @@ private static ReadOnlySpan TrimTrailingWhitespace(ReadOnlySpan line /// /// Removes trailing empty lines that cause unnecessary whitespace in code blocks. /// - private static string[] RemoveTrailingEmptyLines(string[] lines) - { + private static string[] RemoveTrailingEmptyLines(string[] lines) { if (lines.Length == 0) return lines; int lastNonEmptyIndex = lines.Length - 1; // Find the last non-empty line - while (lastNonEmptyIndex >= 0 && string.IsNullOrWhiteSpace(lines[lastNonEmptyIndex])) - { + while (lastNonEmptyIndex >= 0 && string.IsNullOrWhiteSpace(lines[lastNonEmptyIndex])) { lastNonEmptyIndex--; } @@ -215,10 +197,9 @@ private static string[] RemoveTrailingEmptyLines(string[] lines) /// Creates an optimized code panel using Text objects instead of markup strings. /// This eliminates VT escaping issues and improves performance. /// - private static Panel CreateOptimizedCodePanel(string[] codeLines, string language, Theme theme) - { + private static Panel CreateCodePanel(string[] codeLines, string language, Theme theme) { // Get theme colors for code blocks - string[] codeScopes = new[] { "text.html.markdown", "markup.fenced_code.block.markdown" }; + string[] codeScopes = ["text.html.markdown", "markup.fenced_code.block.markdown"]; (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( new MarkdownToken(codeScopes), theme); diff --git a/src/Rendering/HeadingRenderer.cs b/src/Rendering/HeadingRenderer.cs new file mode 100644 index 0000000..56e2f69 --- /dev/null +++ b/src/Rendering/HeadingRenderer.cs @@ -0,0 +1,89 @@ +using System.Text; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Core; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// 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 Paragraph; Spectre.Console Rows will handle block separation + return new Paragraph(headingText, headingStyle); + } + + /// + /// Extracts plain text from heading inline elements without building markup. + /// + private static string ExtractHeadingText(HeadingBlock heading) + => InlineTextExtractor.ExtractAllText(heading.Inline); + + /// + /// Creates appropriate styling for headings based on theme and level. + /// + private static Style CreateHeadingStyle(int foreground, int background, FontStyle fontStyle, Theme theme, int level) { + Color? foregroundColor = null; + Color? backgroundColor = null; + + // 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 decoration = StyleHelper.GetDecoration(fontStyle); + + // Apply level-specific styling as fallbacks + 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/Rendering/HorizontalRuleRenderer.cs similarity index 62% rename from src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs rename to src/Rendering/HorizontalRuleRenderer.cs index 5a52a33..253479e 100644 --- a/src/Core/Markdown/Renderers/HorizontalRuleRenderer.cs +++ b/src/Rendering/HorizontalRuleRenderer.cs @@ -1,19 +1,16 @@ using Spectre.Console; using Spectre.Console.Rendering; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Renders markdown horizontal rules (thematic breaks). /// -internal static class HorizontalRuleRenderer -{ +internal static class HorizontalRuleRenderer { /// /// Renders a horizontal rule as a styled line. /// /// Rendered horizontal rule public static IRenderable Render() - { - return new Rule().RuleStyle(Style.Parse("grey")); - } + => new Rule().RuleStyle(Style.Parse("grey")); } diff --git a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs b/src/Rendering/HtmlBlockRenderer.cs similarity index 70% rename from src/Core/Markdown/Renderers/HtmlBlockRenderer.cs rename to src/Rendering/HtmlBlockRenderer.cs index 693a5e7..8273e87 100644 --- a/src/Core/Markdown/Renderers/HtmlBlockRenderer.cs +++ b/src/Rendering/HtmlBlockRenderer.cs @@ -1,16 +1,16 @@ using Markdig.Syntax; +using PSTextMate.Core; using Spectre.Console; using Spectre.Console.Rendering; using TextMateSharp.Grammars; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Renders HTML blocks with syntax highlighting. /// -internal static class HtmlBlockRenderer -{ +internal static class HtmlBlockRenderer { /// /// Renders an HTML block with syntax highlighting when possible. /// @@ -18,23 +18,19 @@ internal static class HtmlBlockRenderer /// Theme for styling /// Theme name for TextMateProcessor /// Rendered HTML block in a panel - public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName themeName) - { + public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName themeName) { List htmlLines = ExtractHtmlLines(htmlBlock); // Try to render with HTML syntax highlighting - try - { - Rows? htmlRows = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); - if (htmlRows is not null) - { - return new Panel(htmlRows) + try { + IRenderable[]? htmlRenderables = TextMateProcessor.ProcessLinesCodeBlock([.. htmlLines], themeName, "html", false); + if (htmlRenderables is not null) { + return new Panel(new Rows(htmlRenderables)) .Border(BoxBorder.Rounded) .Header("html", Justify.Left); } } - catch - { + catch { // Fallback to plain rendering } @@ -45,12 +41,10 @@ public static IRenderable Render(HtmlBlock htmlBlock, Theme theme, ThemeName the /// /// Extracts HTML lines from the HTML block. /// - private static List ExtractHtmlLines(HtmlBlock htmlBlock) - { + private static List ExtractHtmlLines(HtmlBlock htmlBlock) { var htmlLines = new List(); - for (int i = 0; i < htmlBlock.Lines.Count; i++) - { + for (int i = 0; i < htmlBlock.Lines.Count; i++) { Markdig.Helpers.StringLine line = htmlBlock.Lines.Lines[i]; htmlLines.Add(line.Slice.ToString()); } @@ -61,11 +55,10 @@ private static List ExtractHtmlLines(HtmlBlock htmlBlock) /// /// Creates a fallback HTML panel when syntax highlighting fails. /// - private static Panel CreateFallbackHtmlPanel(List htmlLines) - { - string? htmlText = Markup.Escape(string.Join("\n", htmlLines)); - - return new Panel(new Markup(htmlText)) + private static Panel CreateFallbackHtmlPanel(List htmlLines) { + string htmlText = string.Join("\n", htmlLines); + var text = new Text(htmlText, Style.Plain); + return new Panel(text) .Border(BoxBorder.Rounded) .Header("html", Justify.Left); } diff --git a/src/Rendering/ImageBlockRenderer.cs b/src/Rendering/ImageBlockRenderer.cs new file mode 100644 index 0000000..de0a9f8 --- /dev/null +++ b/src/Rendering/ImageBlockRenderer.cs @@ -0,0 +1,193 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace PSTextMate.Rendering; + +/// +/// Handles rendering of images at the block level with support for captions and layouts. +/// Demonstrates how to embed SixelImage in various Spectre.Console containers. +/// +internal static class ImageBlockRenderer { + /// + /// Renders an image with optional caption using appropriate container. + /// SixelImage can be embedded in Panel, Columns, Rows, Grid, Table cells, or rendered directly. + /// + /// Alternative text / caption for the image + /// URL or path to the image + /// How to render the image (direct, panel, columns, rows) + /// A renderable containing the image + public static IRenderable? RenderImageBlock( + string altText, + string imageUrl, + ImageRenderMode renderMode = ImageRenderMode.Direct) { + + // Get the base image renderable (either SixelImage or fallback) + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + + if (imageRenderable is null) return null; + + // Apply the rendering mode to wrap/position the image + return renderMode switch { + // Render directly (no wrapper) - good for standalone images + ImageRenderMode.Direct + => imageRenderable, + + // Wrap in Panel with title - good for captioned images + ImageRenderMode.PanelWithCaption when !string.IsNullOrEmpty(altText) + => new Panel(imageRenderable) + .Header(altText.EscapeMarkup()) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey), + + // Wrap in Panel without title + ImageRenderMode.PanelWithCaption + => new Panel(imageRenderable) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey), + + // Wrap with padding + ImageRenderMode.WithPadding + => new Padder(imageRenderable, new Padding(1, 0)), + ImageRenderMode.SideCaption + => RenderImageWithSideCaption(altText, imageUrl, altText), + ImageRenderMode.VerticalCaption + => RenderImageWithVerticalCaption(altText, imageUrl, altText), + ImageRenderMode.Grid + => RenderImageInGrid(altText, imageUrl, topCaption: altText), + ImageRenderMode.TableCell + => RenderImageInTable(altText, imageUrl, altText), + _ => imageRenderable + }; + } + + /// + /// Renders image with text caption in a two-column layout (image | text). + /// Demonstrates using Columns to embed SixelImage with other content. + /// + public static IRenderable? RenderImageWithSideCaption( + string altText, + string imageUrl, + string caption) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + if (imageRenderable is null) { + return null; + } + + // Create a captioned text panel using Text to avoid markup parsing + var captionText = new Text(caption ?? string.Empty, Style.Plain); + Panel captionPanel = new Panel(captionText) + .Border(BoxBorder.None) + .Padding(0, 1); // Padding on sides + + // Arrange image and caption side-by-side using Columns + // This is how you embed SixelImage (or any IRenderable) horizontally + return new Columns(imageRenderable, captionPanel); + } + + /// + /// Renders image with caption stacked vertically (image on top, caption below). + /// Demonstrates using Rows to embed SixelImage with other content. + /// + public static IRenderable? RenderImageWithVerticalCaption( + string altText, + string imageUrl, + string caption) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + if (imageRenderable is null) { + return null; + } + + // Create caption text + var captionText2 = new Text(caption ?? string.Empty, Style.Plain); + + // Arrange vertically using Rows + return new Rows( + imageRenderable, + new Padder(captionText2, new Padding(0, 1)) // Padding above caption + ); + } + + /// + /// Renders image in a grid layout with optional surrounding content. + /// Demonstrates using Grid to embed SixelImage with flexible positioning. + /// + public static IRenderable? RenderImageInGrid( + string altText, + string imageUrl, + string? topCaption = null, + string? bottomCaption = null) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + if (imageRenderable is null) { + return null; + } + + Grid grid = new Grid() + .AddColumn(new GridColumn { NoWrap = false }) + .AddRow(new Text(topCaption ?? string.Empty, Style.Plain)); + + grid.AddRow(imageRenderable); + + if (!string.IsNullOrEmpty(bottomCaption)) { + grid.AddRow(new Text(bottomCaption, Style.Plain)); + } + + return grid; + } + + /// + /// Renders image in a table cell with text. + /// Demonstrates embedding SixelImage in Table cells. + /// + public static Table RenderImageInTable( + string altText, + string imageUrl, + string caption) { + + IRenderable? imageRenderable = ImageRenderer.RenderImage(altText, imageUrl); + + Table table = new Table() + .AddColumn("Image") + .AddColumn("Caption"); + + if (imageRenderable is not null) { + table.AddRow(imageRenderable, new Text(caption ?? string.Empty, Style.Plain)); + } + else { + table.AddRow( + new Text($"Image failed to load: {imageUrl}", new Style(Color.Grey)), + new Text(caption ?? string.Empty, Style.Plain) + ); + } + + return table; + } +} + +/// +/// Specifies how an image should be rendered at the block level. +/// +internal enum ImageRenderMode { + /// Render image directly without any wrapper (most efficient) + Direct, + + /// Wrap image in a Panel with caption as header (good for titled images) + PanelWithCaption, + + /// Wrap image with padding + WithPadding, + + /// Side-by-side layout with caption (requires additional caption text) + SideCaption, + + /// Vertical stack with caption (requires additional caption text) + VerticalCaption, + + /// Grid layout (most flexible, requires additional content) + Grid, + + /// Table cell (requires additional caption) + TableCell +} diff --git a/src/Core/Markdown/Renderers/ImageRenderer.cs b/src/Rendering/ImageRenderer.cs similarity index 57% rename from src/Core/Markdown/Renderers/ImageRenderer.cs rename to src/Rendering/ImageRenderer.cs index a978ca9..036319c 100644 --- a/src/Core/Markdown/Renderers/ImageRenderer.cs +++ b/src/Rendering/ImageRenderer.cs @@ -1,21 +1,26 @@ using System.Reflection; -using PwshSpectreConsole.TextMate.Core.Helpers; +using PSTextMate.Utilities; using Spectre.Console; using Spectre.Console.Rendering; #pragma warning disable CS0103 // The name 'SixelImage' does not exist in the current context -namespace PwshSpectreConsole.TextMate.Core.Markdown.Renderers; +namespace PSTextMate.Rendering; /// /// Handles rendering of images in markdown using Sixel format when possible. /// -internal static class ImageRenderer -{ +public static class ImageRenderer { private static string? _lastSixelError; private static string? _lastImageError; private static readonly TimeSpan ImageTimeout = TimeSpan.FromSeconds(5); // Increased to 5 seconds + /// + /// The base directory for resolving relative image paths in markdown. + /// Set this before rendering markdown content to enable relative path resolution. + /// + public static string? CurrentMarkdownDirectory { get; set; } + /// /// Renders an image using Sixel format if possible, otherwise falls back to a link. /// @@ -24,52 +29,51 @@ internal static class ImageRenderer /// Maximum width for the image (optional) /// Maximum height for the image (optional) /// A renderable representing the image or fallback - public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) - { - try - { + public static IRenderable RenderImage(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { + try { // Clear previous errors _lastImageError = null; _lastSixelError = null; // Check if the image format is likely supported - if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) - { + if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) { _lastImageError = $"Unsupported image format: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } // Use a timeout for image processing string? localImagePath = null; - Task imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); + Task imageTask = Task.Run(async () => { + string? result = await ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory); + // Track what paths we're trying to resolve for error reporting + if (result is null && CurrentMarkdownDirectory is not null) { + _lastImageError = $"Failed to resolve '{imageUrl}' with base directory '{CurrentMarkdownDirectory}'"; + } + return result; + }); - if (imageTask.Wait(ImageTimeout)) - { + if (imageTask.Wait(ImageTimeout)) { localImagePath = imageTask.Result; } - else - { + else { // Timeout occurred _lastImageError = $"Image download timeout after {ImageTimeout.TotalSeconds} seconds: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } - if (localImagePath is null) - { + if (localImagePath is null) { _lastImageError = $"Failed to normalize image source: {imageUrl}"; return CreateImageFallback(altText, imageUrl); } // Verify the downloaded file exists and has content - if (!File.Exists(localImagePath)) - { + if (!File.Exists(localImagePath)) { _lastImageError = $"Downloaded image file does not exist: {localImagePath}"; return CreateImageFallback(altText, imageUrl); } var fileInfo = new FileInfo(localImagePath); - if (fileInfo.Length == 0) - { + if (fileInfo.Length == 0) { _lastImageError = $"Downloaded image file is empty: {localImagePath} (0 bytes)"; return CreateImageFallback(altText, imageUrl); } @@ -78,19 +82,18 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW int defaultMaxWidth = maxWidth ?? 80; // Default to ~80 characters wide for terminal display int defaultMaxHeight = maxHeight ?? 30; // Default to ~30 lines high - if (TryCreateSixelImage(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) - { + if (TryCreateSixelRenderable(localImagePath, defaultMaxWidth, defaultMaxHeight, out IRenderable? sixelImage) && sixelImage is not null) { + // Return the sixel image directly. The caller may append an explicit Text.NewLine + // so it renders as a separate row (avoids embedding the blank row inside the same widget). return sixelImage; } - else - { + else { // Fallback to enhanced link representation with file info _lastImageError = $"SixelImage creation failed. File: {localImagePath} ({fileInfo.Length} bytes). Sixel error: {_lastSixelError}"; return CreateEnhancedImageFallback(altText, imageUrl, localImagePath); } } - catch (Exception ex) - { + catch (Exception ex) { // If anything goes wrong, fall back to the basic link representation _lastImageError = $"Exception in RenderImage: {ex.Message}"; return CreateImageFallback(altText, imageUrl); @@ -105,32 +108,26 @@ public static IRenderable RenderImage(string altText, string imageUrl, int? maxW /// Maximum width for the image (optional) /// Maximum height for the image (optional) /// A renderable representing the image or fallback - public static IRenderable RenderImageInline(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) - { - try - { + public static IRenderable RenderImageInline(string altText, string imageUrl, int? maxWidth = null, int? maxHeight = null) { + try { // Check if the image format is likely supported - if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) - { + if (!ImageFile.IsLikelySupportedImageFormat(imageUrl)) { return CreateImageFallbackInline(altText, imageUrl); } // Use a timeout for image processing string? localImagePath = null; - Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl)); + Task? imageTask = Task.Run(async () => await ImageFile.NormalizeImageSourceAsync(imageUrl, CurrentMarkdownDirectory)); - if (imageTask.Wait(ImageTimeout)) - { + if (imageTask.Wait(ImageTimeout)) { localImagePath = imageTask.Result; } - else - { + else { // Timeout occurred return CreateImageFallbackInline(altText, imageUrl); } - if (localImagePath is null) - { + if (localImagePath is null) { return CreateImageFallbackInline(altText, imageUrl); } @@ -138,77 +135,113 @@ public static IRenderable RenderImageInline(string altText, string imageUrl, int int width = maxWidth ?? 60; // Default max width for inline images int height = maxHeight ?? 20; // Default max height for inline images - if (TryCreateSixelImage(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) - { + if (TryCreateSixelRenderable(localImagePath, width, height, out IRenderable? sixelImage) && sixelImage is not null) { return sixelImage; } - else - { + else { // Fallback to inline link representation return CreateImageFallbackInline(altText, imageUrl); } } - catch - { + catch { // If anything goes wrong, fall back to the link representation return CreateImageFallbackInline(altText, imageUrl); } } /// - /// Attempts to create a SixelImage using reflection for forward compatibility. + /// Attempts to create a sixel renderable using the newest available implementation. /// - /// Path to the image file - /// Maximum width - /// Maximum height - /// The created SixelImage, if successful - /// True if SixelImage was successfully created - private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) - { + private static bool TryCreateSixelRenderable(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) + => TryCreatePixelImage(imagePath, maxWidth, out result) || TryCreateSpectreSixelImage(imagePath, maxWidth, maxHeight, out result); + + /// + /// Attempts to create a PixelImage from PwshSpectreConsole using reflection. + /// + private static bool TryCreatePixelImage(string imagePath, int? maxWidth, out IRenderable? result) { result = null; - try - { - // Try multiple approaches to find SixelImage - Type? sixelImageType = null; + try { + var pixelImageType = Type.GetType("PwshSpectreConsole.PixelImage, PwshSpectreConsole"); + if (pixelImageType is null) { + return false; + } + + ConstructorInfo? constructor = pixelImageType.GetConstructor([typeof(string), typeof(bool)]); + if (constructor is null) { + _lastSixelError = "Constructor not found for PixelImage with (string, bool) parameters"; + return false; + } + + object? pixelInstance; + try { + pixelInstance = constructor.Invoke([imagePath, false]); + } + catch (Exception ex) { + _lastSixelError = $"Failed to invoke PixelImage constructor: {ex.InnerException?.Message ?? ex.Message}"; + return false; + } - // First, try the direct approach - SixelImage is in Spectre.Console namespace + if (pixelInstance is null) { + _lastSixelError = "PixelImage constructor returned null"; + return false; + } + + if (maxWidth.HasValue) { + PropertyInfo? maxWidthProperty = pixelImageType.GetProperty("MaxWidth"); + if (maxWidthProperty?.CanWrite == true) { + maxWidthProperty.SetValue(pixelInstance, maxWidth.Value); + } + } + + if (pixelInstance is IRenderable renderable) { + result = renderable; + return true; + } + } + catch (Exception ex) { + _lastSixelError = ex.Message; + } + + return false; + } + + /// + /// Attempts to create a Spectre.Console SixelImage using reflection for backward compatibility. + /// + private static bool TryCreateSpectreSixelImage(string imagePath, int? maxWidth, int? maxHeight, out IRenderable? result) { + result = null; + + try { + // Try the direct approach - SixelImage is in Spectre.Console namespace // but might be in different assemblies (Spectre.Console vs Spectre.Console.ImageSharp) - sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); + Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") + ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); // If that fails, search through loaded assemblies - if (sixelImageType is null) - { - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { + if (sixelImageType is null) { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { string? assemblyName = assembly.GetName().Name; - if (assemblyName?.Contains("Spectre.Console") == true) - { + if (assemblyName?.Contains("Spectre.Console") == true) { // SixelImage is in Spectre.Console namespace regardless of assembly sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); - if (sixelImageType is not null) - { + if (sixelImageType is not null) { break; } } } } - if (sixelImageType is null) - { + if (sixelImageType is null) { // Debug: Let's see what Spectre.Console types are available - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - if (assembly.GetName().Name?.Contains("Spectre.Console") == true) - { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { + if (assembly.GetName().Name?.Contains("Spectre.Console") == true) { string?[]? spectreTypes = [.. assembly.GetTypes() .Where(t => t.Name.Contains("Sixel", StringComparison.OrdinalIgnoreCase)) .Select(t => t.FullName) .Where(name => name is not null)]; - if (spectreTypes.Length > 0) - { + if (spectreTypes.Length > 0) { // Found some Sixel-related types, try the first one sixelImageType = assembly.GetType(spectreTypes[0]!); break; @@ -217,69 +250,66 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma } } - if (sixelImageType is null) - { + if (sixelImageType is null) { return false; } // Create SixelImage instance ConstructorInfo? constructor = sixelImageType.GetConstructor([typeof(string), typeof(bool)]); - if (constructor is null) - { + if (constructor is null) { + _lastSixelError = "Constructor not found for SixelImage with (string, bool) parameters"; return false; } - object? sixelInstance = constructor.Invoke([imagePath, false]); // false = animation enabled - if (sixelInstance is null) - { + object? sixelInstance; + try { + sixelInstance = constructor.Invoke([imagePath, false]); // false = animation disabled + } + catch (Exception ex) { + _lastSixelError = $"Failed to invoke SixelImage constructor: {ex.InnerException?.Message ?? ex.Message}"; + return false; + } + + if (sixelInstance is null) { + _lastSixelError = "SixelImage constructor returned null"; return false; } // Apply size constraints if available - if (maxWidth.HasValue) - { + if (maxWidth.HasValue) { PropertyInfo? maxWidthProperty = sixelImageType.GetProperty("MaxWidth"); - if (maxWidthProperty is not null && maxWidthProperty.CanWrite) - { + if (maxWidthProperty?.CanWrite == true) { maxWidthProperty.SetValue(sixelInstance, maxWidth.Value); } - else - { + else { // Try method-based approach as fallback MethodInfo? maxWidthMethod = sixelImageType.GetMethod("MaxWidth"); - if (maxWidthMethod is not null) - { + if (maxWidthMethod is not null) { sixelInstance = maxWidthMethod.Invoke(sixelInstance, [maxWidth.Value]); } } } - if (maxHeight.HasValue) - { + if (maxHeight.HasValue) { PropertyInfo? maxHeightProperty = sixelImageType.GetProperty("MaxHeight"); - if (maxHeightProperty?.CanWrite == true) - { + if (maxHeightProperty?.CanWrite == true) { maxHeightProperty.SetValue(sixelInstance, maxHeight.Value); } - else - { + else { // Try method-based approach as fallback MethodInfo? maxHeightMethod = sixelImageType.GetMethod("MaxHeight"); - if (maxHeightMethod is not null) - { + if (maxHeightMethod is not null) { sixelInstance = maxHeightMethod.Invoke(sixelInstance, [maxHeight.Value]); } } } - if (sixelInstance is IRenderable renderable) - { + if (sixelInstance is IRenderable renderable) { result = renderable; return true; } } - catch (Exception ex) - { + catch (Exception ex) { // Capture the error for debugging _lastSixelError = ex.Message; } @@ -293,11 +323,10 @@ private static bool TryCreateSixelImage(string imagePath, int? maxWidth, int? ma /// Alternative text for the image /// URL or path to the image /// A markup string representing the image as a link - private static Markup CreateImageFallback(string altText, string imageUrl) - { - string? linkText = $"🖼️ Image: {altText.EscapeMarkup()}"; - string? linkMarkup = $"[blue link={imageUrl.EscapeMarkup()}]{linkText}[/]"; - return new Markup(linkMarkup); + private static Text CreateImageFallback(string altText, string imageUrl) { + string linkText = $"🖼️ Image: {altText}"; + var style = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + return new Text(linkText, style); } /// @@ -307,22 +336,21 @@ private static Markup CreateImageFallback(string altText, string imageUrl) /// Original URL or path to the image /// Local path to the image file /// A panel with enhanced image information - private static IRenderable CreateEnhancedImageFallback(string altText, string imageUrl, string localPath) - { - try - { - var fileInfo = new System.IO.FileInfo(localPath); + private static IRenderable CreateEnhancedImageFallback(string altText, string imageUrl, string localPath) { + try { + var fileInfo = new FileInfo(localPath); string? sizeText = fileInfo.Exists ? $" ({fileInfo.Length / 1024:N0} KB)" : ""; - var content = new Markup($"🖼️ [blue link={imageUrl.EscapeMarkup()}]{altText.EscapeMarkup()}[/]{sizeText}"); - - return new Panel(content) - .Header("[grey]Image (Sixel not available)[/]") + // Build a text-based content with clickable link style + string display = $"🖼️ {altText}{sizeText}"; + var linkStyle = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + var text = new Text(display, linkStyle); + return new Panel(text) + .Header("Image (Sixel not available)") .Border(BoxBorder.Rounded) .BorderColor(Color.Grey); } - catch - { + catch { return CreateImageFallback(altText, imageUrl); } } @@ -333,82 +361,42 @@ private static IRenderable CreateEnhancedImageFallback(string altText, string im /// Alternative text for the image /// URL or path to the image /// A markup string representing the image as a link - private static Markup CreateImageFallbackInline(string altText, string imageUrl) - { - string? linkText = $"🖼️ {altText.EscapeMarkup()}"; - string? linkMarkup = $"[blue link={imageUrl.EscapeMarkup()}]{linkText}[/]"; - return new Markup(linkMarkup); - } - - /// - /// 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)); + private static Text CreateImageFallbackInline(string altText, string imageUrl) { + string display = $"🖼️ {altText}"; + var style = new Style(Color.Blue, null, Decoration.Underline, imageUrl); + return new Text(display, style); } /// /// Gets debug information about the last image processing error. /// /// The last error message, if any - public static string? GetLastImageError() - { - return _lastImageError; - } + public static string? GetLastImageError() => _lastImageError; /// /// Gets debug information about the last Sixel error. /// /// The last error message, if any - public static string? GetLastSixelError() - { - return _lastSixelError; - } + public static string? GetLastSixelError() => _lastSixelError; /// /// Checks if SixelImage type is available in the current environment. /// /// True if SixelImage can be found - public static bool IsSixelImageAvailable() - { - try - { - Type? sixelImageType = null; + public static bool IsSixelImageAvailable() { + try { // Try direct approaches first - sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") - ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); + Type? sixelImageType = Type.GetType("Spectre.Console.SixelImage, Spectre.Console.ImageSharp") + ?? Type.GetType("Spectre.Console.SixelImage, Spectre.Console"); if (sixelImageType is not null) return true; // Search through loaded assemblies - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { string? assemblyName = assembly.GetName().Name; - if (assemblyName?.Contains("Spectre.Console") == true) - { + if (assemblyName?.Contains("Spectre.Console") == true) { sixelImageType = assembly.GetType("Spectre.Console.SixelImage"); if (sixelImageType is not null) return true; @@ -417,8 +405,7 @@ public static bool IsSixelImageAvailable() return false; } - catch - { + catch { return false; } } diff --git a/src/Rendering/ListRenderer.cs b/src/Rendering/ListRenderer.cs new file mode 100644 index 0000000..98a6d9c --- /dev/null +++ b/src/Rendering/ListRenderer.cs @@ -0,0 +1,231 @@ +using System.Text; +using Markdig.Extensions.TaskLists; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// 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 items as individual Paragraphs (one per line) + public static IEnumerable Render(ListBlock list, Theme theme) { + var renderables = new List(); + int number = 1; + + foreach (ListItemBlock item in list.Cast()) { + var itemParagraph = new Paragraph(); + + (bool isTaskList, bool isChecked) = DetectTaskListItem(item); + + string prefixText = CreateListPrefixText(list.IsOrdered, isTaskList, isChecked, ref number); + itemParagraph.Append(prefixText, Style.Plain); + + List nestedRenderables = AppendListItemContent(itemParagraph, item, theme); + + renderables.Add(itemParagraph); + if (nestedRenderables.Count > 0) { + renderables.AddRange(nestedRenderables); + } + } + + // Return items individually - each list item is its own line + return renderables; + } + + /// + /// 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) + => isTaskList ? isChecked ? TaskCheckedEmoji : TaskUncheckedEmoji : isOrdered ? $"{number++}. " : UnorderedBullet; + + /// + /// Appends list item content directly to the paragraph using styled Text objects. + /// This eliminates the need for markup parsing and VT escaping. + /// + private static List AppendListItemContent(Paragraph paragraph, ListItemBlock item, Theme theme) { + var nestedRenderables = new List(); + + 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: + string nestedContent = RenderNestedListAsText(nestedList, theme, 1); + if (!string.IsNullOrEmpty(nestedContent)) { + var nestedParagraph = new Paragraph(); + nestedParagraph.Append(nestedContent, Style.Plain); + nestedRenderables.Add(nestedParagraph); + } + break; + default: + break; + } + } + + return nestedRenderables; + } + + /// + /// Processes inline content and builds markup for list items. + /// + private static void AppendInlineContent(Paragraph paragraph, ContainerInline? inlines, Theme theme) { + if (inlines is null) return; + + foreach (Inline inline in new List(inlines)) { + switch (inline) { + case LiteralInline literal: + string literalText = literal.Content.ToString(); + if (!string.IsNullOrEmpty(literalText)) { + paragraph.Append(literalText, Style.Plain); + } + break; + + case CodeInline code: + paragraph.Append(code.Content, Style.Plain); + break; + + case LinkInline link when !link.IsImage: + string linkText = ExtractInlineText(link); + if (string.IsNullOrEmpty(linkText)) { + linkText = link.Url ?? ""; + } + var linkStyle = new Style(Color.Blue, null, Decoration.Underline, link.Url); + paragraph.Append(linkText, linkStyle); + break; + + case LineBreakInline: + // Skip line breaks in list items + break; + + default: + paragraph.Append(ExtractInlineText(inline), Style.Plain); + break; + } + } + } + + /// + /// Extracts plain text from inline elements without markup. + /// + private static string ExtractInlineText(Inline inline) { + StringBuilder builder = StringBuilderPool.Rent(); + try { + InlineTextExtractor.ExtractText(inline, builder); + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } + } + + + + /// + /// Renders nested lists as indented text content. + /// + private static string RenderNestedListAsText(ListBlock list, Theme theme, int indentLevel) { + StringBuilder builder = StringBuilderPool.Rent(); + try { + string indent = new(' ', indentLevel * 4); + int number = 1; + bool isFirstItem = true; + + foreach (ListItemBlock item in list.Cast()) { + if (!isFirstItem) { + builder.Append('\n'); + } + + 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(); + } + finally { + StringBuilderPool.Return(builder); + } + } + + /// + /// Simple text extraction for nested list items. + /// + private static string ExtractListItemTextSimple(ListItemBlock item) { + StringBuilder builder = StringBuilderPool.Rent(); + try { + foreach (Block subBlock in item) { + if (subBlock is ParagraphBlock subPara && subPara.Inline is not null) { + foreach (Inline inline in subPara.Inline) { + if (inline is not TaskList) { + // Skip TaskList markers + builder.Append(ExtractInlineText(inline)); + } + } + } + else if (subBlock is CodeBlock subCode) { + builder.Append(subCode.Lines.ToString()); + } + } + + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } + } +} diff --git a/src/Rendering/MarkdownRenderer.cs b/src/Rendering/MarkdownRenderer.cs new file mode 100644 index 0000000..5279221 --- /dev/null +++ b/src/Rendering/MarkdownRenderer.cs @@ -0,0 +1,113 @@ +using Markdig; +using Markdig.Helpers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// Markdown renderer that builds Spectre.Console objects directly instead of markup strings. +/// This eliminates VT escaping issues and avoids double-parsing overhead for better performance. +/// +internal static class MarkdownRenderer { + /// + /// Cached Markdig pipeline with trivia tracking enabled. + /// Pipelines are expensive to create, so we cache it as a static field for reuse. + /// Thread-safe: Markdig pipelines are immutable once built. + /// + private static readonly MarkdownPipeline _pipeline = CreateMarkdownPipeline(); + + /// + /// Renders markdown content using Spectre.Console object building. + /// This approach eliminates VT escaping issues and improves performance. + /// + /// Markdown text (can be multi-line) + /// Theme object for styling + /// Theme name for TextMateProcessor + /// Array of renderables for Spectre.Console rendering + public static IRenderable[] Render(string markdown, Theme theme, ThemeName themeName) { + MarkdownDocument? document = Markdown.Parse(markdown, _pipeline); + + var rows = new List(); + Block? previousBlock = null; + + for (int i = 0; i < document.Count; i++) { + Block? block = document[i]; + + // Skip redundant paragraph that Markdig sometimes produces on the same line as a table + if (block is ParagraphBlock && i + 1 < document.Count) { + Block nextBlock = document[i + 1]; + if (nextBlock is Markdig.Extensions.Tables.Table table && block.Line == table.Line) { + continue; + } + } + + // Calculate blank lines from source line numbers + // This is more reliable than trivia since extensions break trivia tracking + if (previousBlock is not null) { + int previousEndLine = GetBlockEndLine(previousBlock, markdown); + int gap = block.Line - previousEndLine - 1; + for (int j = 0; j < gap; j++) { + rows.Add(new Rows(Text.Empty)); + } + } + + // Render the block - returns IEnumerable + rows.AddRange(BlockRenderer.RenderBlock(block, theme, themeName)); + + previousBlock = block; + } + return [.. rows]; + } + + /// + /// Gets the ending line number of a block by counting newlines in the source span. + /// + private static int GetBlockEndLine(Block block, string markdown) { + // For container blocks, recursively find the last child's end line + if (block is ContainerBlock container && container.Count > 0) { + return GetBlockEndLine(container[^1], markdown); + } + // For fenced code blocks: opening fence + content lines + closing fence + if (block is FencedCodeBlock fenced && fenced.Lines.Count > 0) { + return block.Line + fenced.Lines.Count + 1; + } + // Count newlines within the block's span (excluding the final newline which separates blocks) + // The span typically includes the trailing newline, so we stop before Span.End + int endPosition = Math.Min(block.Span.End - 1, markdown.Length - 1); + int newlineCount = 0; + for (int i = block.Span.Start; i <= endPosition; i++) { + if (markdown[i] == '\n') { + newlineCount++; + } + } + return block.Line + newlineCount; + } + + /// + /// Returns true for blocks that render with visual borders and need padding. + /// + private static bool IsBorderedBlock(Block block) => + block is QuoteBlock or FencedCodeBlock or HtmlBlock or Markdig.Extensions.Tables.Table; + + /// + /// Creates the Markdig pipeline with all necessary extensions and trivia tracking enabled. + /// Pipeline follows Markdig's roundtrip parser design pattern - see: + /// https://github.com/xoofx/markdig/blob/master/src/Markdig/Roundtrip.md + /// + /// Configured MarkdownPipeline with trivia tracking enabled + private static MarkdownPipeline CreateMarkdownPipeline() { + return new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseTaskLists() + .UsePipeTables() + .UseAutoLinks() + .EnableTrackTrivia() + .Build(); + } +} diff --git a/src/Rendering/ParagraphRenderer.cs b/src/Rendering/ParagraphRenderer.cs new file mode 100644 index 0000000..2892d70 --- /dev/null +++ b/src/Rendering/ParagraphRenderer.cs @@ -0,0 +1,305 @@ +using System.Text; +using System.Text.RegularExpressions; +using Markdig.Extensions; +using Markdig.Extensions.AutoLinks; +using Markdig.Extensions.TaskLists; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Core; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// Paragraph renderer that builds Spectre.Console objects directly instead of markup strings. +/// Uses Text widgets instead of Paragraph to avoid extra spacing in terminal output. +/// This eliminates VT escaping issues and avoids double-parsing overhead. +/// +internal static partial class ParagraphRenderer { + // reuse static arrays for common scope queries to avoid allocating new arrays per call + private static readonly string[] LinkScope = ["markup.underline.link"]; + + /// + /// Renders a paragraph block by building Text objects with proper Style including link parameter. + /// Avoids Paragraph widget spacing AND markup parsing overhead. + /// Uses Style(foreground, background, decoration, link) for clickable links and styled code. + /// + /// The paragraph block to render + /// Theme for styling + /// When true, emits one Paragraph per line break + /// Text segments with proper styling + public static IEnumerable Render(ParagraphBlock paragraph, Theme theme, bool splitOnLineBreaks = false) { + var segments = new List(); + + if (paragraph.Inline is not null) { + BuildTextSegments(segments, paragraph.Inline, theme, splitOnLineBreaks: splitOnLineBreaks); + } + + return segments; + } + + /// + /// Builds Text segments from inline elements with proper Style objects. + /// Accumulates plain text and flushes when style changes (code, links). + /// + private static void BuildTextSegments(List segments, ContainerInline inlines, Theme theme, bool skipLineBreaks = false, bool splitOnLineBreaks = false) { + var paragraph = new Paragraph(); + bool addedAny = false; + + List inlineList = [.. inlines]; + + for (int i = 0; i < inlineList.Count; i++) { + Inline inline = inlineList[i]; + + bool isTrailingLineBreak = false; + if (inline is LineBreakInline && i < inlineList.Count) { + isTrailingLineBreak = true; + for (int j = i + 1; j < inlineList.Count; j++) { + if (inlineList[j] is not LineBreakInline) { + isTrailingLineBreak = false; + break; + } + } + } + + switch (inline) { + case LiteralInline literal: { + string literalText = literal.Content.ToString(); + if (!string.IsNullOrEmpty(literalText)) { + if (TryParseUsernameLinks(literalText, out TextSegment[]? usernameSegments)) { + foreach (TextSegment segment in usernameSegments) { + if (segment.IsUsername) { + var usernameStyle = new Style(Color.Blue, null, Decoration.Underline, $"https://github.com/{segment.Text.TrimStart('@')}"); + paragraph.Append(segment.Text, usernameStyle); + addedAny = true; + } + else { + paragraph.Append(segment.Text, Style.Plain); + addedAny = true; + } + } + } + else { + paragraph.Append(literalText, Style.Plain); + addedAny = true; + } + } + } + break; + + case EmphasisInline emphasis: { + Decoration decoration = GetEmphasisDecoration(emphasis.DelimiterCount); + var emphasisStyle = new Style(null, null, decoration); + + foreach (Inline emphInline in emphasis) { + switch (emphInline) { + case LiteralInline lit: + paragraph.Append(lit.Content.ToString(), emphasisStyle); + addedAny = true; + break; + case CodeInline codeInline: + paragraph.Append(codeInline.Content, GetCodeStyle(theme)); + addedAny = true; + break; + case LinkInline linkInline: + // Build link style and include emphasis decoration + string linkText = ExtractInlineText(linkInline); + if (string.IsNullOrEmpty(linkText)) linkText = linkInline.Url ?? ""; + Style baseLink = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); + var combined = new Style(baseLink.Foreground, baseLink.Background, baseLink.Decoration | decoration | Decoration.Underline, linkInline.Url); + paragraph.Append(linkText, combined); + addedAny = true; + break; + default: + paragraph.Append(ExtractInlineText(emphInline), emphasisStyle); + addedAny = true; + break; + } + } + } + break; + + case CodeInline code: + paragraph.Append(code.Content, GetCodeStyle(theme)); + addedAny = true; + break; + + case LinkInline link: + ProcessLinkAsText(paragraph, link, theme); + addedAny = true; + break; + + case AutolinkInline autoLink: + ProcessAutoLinkAsText(paragraph, autoLink, theme); + addedAny = true; + break; + + case LineBreakInline: + if (!skipLineBreaks && !isTrailingLineBreak) { + if (splitOnLineBreaks) { + if (addedAny) { + segments.Add(paragraph); + } + paragraph = new Paragraph(); + addedAny = false; + } + else { + paragraph.Append("\n", Style.Plain); + addedAny = true; + } + } + break; + + case HtmlInline html: + paragraph.Append(html.Tag ?? "", Style.Plain); + addedAny = true; + break; + + case TaskList: + break; + + default: + paragraph.Append(ExtractInlineText(inline), Style.Plain); + addedAny = true; + break; + } + } + + if (addedAny) { + segments.Add(paragraph); + } + } + + /// + /// Process link as Text with Style including link parameter for clickability. + /// + private static void ProcessLinkAsText(Paragraph paragraph, LinkInline link, Theme theme) { + if (link.IsImage) { + string altText = ExtractInlineText(link); + if (string.IsNullOrEmpty(altText)) altText = "Image"; + string imageLinkText = $"🖼️ {altText}"; + var style = new Style(Color.Blue, null, Decoration.Underline, link.Url); + paragraph.Append(imageLinkText, style); + return; + } + + string linkText = ExtractInlineText(link); + if (string.IsNullOrEmpty(linkText)) linkText = link.Url ?? ""; + + Style linkStyle = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); + // Create new style with link parameter + var styledLink = new Style(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline, link.Url); + paragraph.Append(linkText, styledLink); + } + + /// + /// Process autolink as Text with Style including link parameter. + /// + private static void ProcessAutoLinkAsText(Paragraph paragraph, AutolinkInline autoLink, Theme theme) { + string url = autoLink.Url ?? ""; + if (string.IsNullOrEmpty(url)) return; + + Style linkStyle = GetLinkStyle(theme) ?? new Style(Color.Blue, null, Decoration.Underline); + var styledLink = new Style(linkStyle.Foreground, linkStyle.Background, linkStyle.Decoration | Decoration.Underline, url); + paragraph.Append(url, styledLink); + } + + /// + /// Get link style from theme. + /// + private static Style? GetLinkStyle(Theme theme) + => TokenProcessor.GetStyleForScopes(LinkScope, theme); + + /// + /// Get code style from theme. + /// + private static Style GetCodeStyle(Theme theme) { + string[] codeScopes = ["markup.inline.raw"]; + (int codeFg, int codeBg, FontStyle codeFs) = TokenProcessor.ExtractThemeProperties( + new MarkdownToken(codeScopes), theme); + + 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); + + return new Style(foregroundColor, backgroundColor, decoration); + } + + /// + /// Extracts plain text from inline elements without markup. + /// + private static string ExtractInlineText(Inline inline) { + StringBuilder builder = StringBuilderPool.Rent(); + try { + InlineTextExtractor.ExtractText(inline, builder); + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } + } + + /// + /// Determine decoration to use for emphasis based on delimiter count and environment fallback. + /// If environment variable `PSTEXTMATE_EMPHASIS_FALLBACK` == "underline" then use underline + /// for single-asterisk emphasis so italics are visible on terminals that do not support italic. + /// + private static Decoration GetEmphasisDecoration(int delimiterCount) { + // Read once per call; environment lookups are cheap here since rendering isn't hot inner loop + string? fallback = Environment.GetEnvironmentVariable("PSTEXTMATE_EMPHASIS_FALLBACK"); + + return delimiterCount switch { + 1 => string.Equals(fallback, "underline", StringComparison.OrdinalIgnoreCase) ? Decoration.Underline : Decoration.Italic, + 2 => Decoration.Bold, + 3 => Decoration.Bold | Decoration.Italic, + _ => Decoration.None, + }; + } + + /// + /// Represents a text segment that may or may not be a username link. + /// + 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 + Regex usernamePattern = RegNumLet(); + MatchCollection matches = usernamePattern.Matches(text); + + if (matches.Count == 0) { + segments = []; + return false; + } + + int lastIndex = 0; + foreach (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]; + return true; + } + + [GeneratedRegex(@"@[a-zA-Z0-9_-]+")] + private static partial Regex RegNumLet(); +} diff --git a/src/Rendering/QuoteRenderer.cs b/src/Rendering/QuoteRenderer.cs new file mode 100644 index 0000000..1ee56ba --- /dev/null +++ b/src/Rendering/QuoteRenderer.cs @@ -0,0 +1,47 @@ +using Markdig.Syntax; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// 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) { + var rows = new List(); + bool needsGap = false; + + foreach (Block subBlock in quote) { + if (needsGap) { + rows.Add(Text.Empty); + } + + if (subBlock is ParagraphBlock paragraph) { + rows.AddRange(ParagraphRenderer.Render(paragraph, theme, splitOnLineBreaks: true)); + } + else { + rows.Add(new Text(subBlock.ToString() ?? string.Empty, Style.Plain)); + } + + needsGap = true; + } + + IRenderable content = rows.Count switch { + 0 => Text.Empty, + 1 => rows[0], + _ => new Rows(rows) + }; + + return new Panel(content) + .Border(BoxBorder.Heavy) + .Header("quote", Justify.Left); + } +} diff --git a/src/Rendering/TableRenderer.cs b/src/Rendering/TableRenderer.cs new file mode 100644 index 0000000..6f39bca --- /dev/null +++ b/src/Rendering/TableRenderer.cs @@ -0,0 +1,214 @@ +using System.Text; +using Markdig.Extensions.Tables; +using Markdig.Helpers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using PSTextMate.Core; +using PSTextMate.Utilities; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Themes; + +namespace PSTextMate.Rendering; + +/// +/// 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 { + ShowFooters = false, + + // Configure table appearance + Border = TableBorder.Rounded, + BorderStyle = GetTableBorderStyle(theme) + }; + + List<(bool isHeader, List cells)> allRows = ExtractTableData(table, theme); + + if (allRows.Count == 0) + return null; + + // Add headers if present + int headerRowIndex = allRows.FindIndex(r => r.isHeader); + if (headerRowIndex >= 0 && allRows[headerRowIndex].cells.Count > 0) { + List headerCells = allRows[headerRowIndex].cells; + for (int i = 0; i < headerCells.Count; i++) { + TableCellContent cell = headerCells[i]; + // Use constructor to set header text; this is the most compatible way + var column = new TableColumn(cell.Text); + // Apply alignment if Markdig specified one for the column + 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 + List firstRowCells = allRows[0].cells; + if (firstRowCells.Count > 0) { + for (int i = 0; i < firstRowCells.Count; i++) { + TableCellContent cell = firstRowCells[i]; + var column = new TableColumn(cell.Text); + if (i < table.ColumnDefinitions.Count) { + column.Alignment = table.ColumnDefinitions[i].Alignment switch { + TableColumnAlign.Left => Justify.Left, + TableColumnAlign.Center => Justify.Center, + TableColumnAlign.Right => Justify.Right, + _ => Justify.Left + }; + } + spectreTable.AddColumn(column); + } + allRows = [.. allRows.Skip(1)]; + } + } + + // 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. + /// + internal sealed record TableCellContent(string Text, TableColumnAlign? Alignment); + + /// + /// Extracts table data cells + /// + internal static List<(bool isHeader, List cells)> ExtractTableData( + Markdig.Extensions.Tables.Table table, Theme theme) { + var result = new List<(bool isHeader, List cells)>(); + + foreach (Markdig.Extensions.Tables.TableRow row in table.Cast()) { + bool isHeader = row.IsHeader; + var cells = new List(); + + for (int i = 0; i < row.Count; i++) { + if (row[i] is TableCell cell) { + string cellText = ExtractCellText(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 ExtractCellText(TableCell cell, Theme theme) { + StringBuilder textBuilder = StringBuilderPool.Rent(); + + foreach (Block block in cell) { + if (block is ParagraphBlock paragraph && paragraph.Inline is not null) { + ExtractInlineText(paragraph.Inline, textBuilder); + } + else if (block is CodeBlock code) { + textBuilder.Append(code.Lines.ToString()); + } + } + + string result = textBuilder.ToString().Trim(); + StringBuilderPool.Return(textBuilder); + return result; + } + + /// + /// Extracts text from inline elements optimized for table cells. + /// + private static void ExtractInlineText(ContainerInline inlines, StringBuilder builder) { + // Small optimization: use a borrowed buffer for frequently accessed literal content instead of repeated ToString allocations. + foreach (Inline inline in inlines) { + switch (inline) { + case LiteralInline literal: + // Append span directly from the underlying string to avoid creating intermediate allocations + StringSlice slice = literal.Content; + if (slice.Text is not null && slice.Length > 0) { + builder.Append(slice.Text.AsSpan(slice.Start, slice.Length)); + } + break; + + case EmphasisInline emphasis: + InlineTextExtractor.ExtractText(emphasis, builder); + break; + + case CodeInline code: + builder.Append(code.Content); + break; + + case LinkInline link: + InlineTextExtractor.ExtractText(link, builder); + break; + + default: + InlineTextExtractor.ExtractText(inline, builder); + break; + } + } + } + + + + /// + /// Gets the border style for tables based on theme. + /// + private static Style GetTableBorderStyle(Theme theme) { + string[] borderScopes = ["punctuation.definition.table"]; + Style? style = TokenProcessor.GetStyleForScopes(borderScopes, theme); + return style is not null ? style : new Style(foreground: Color.Grey); + } + + /// + /// Gets the header style for table headers. + /// + private static Style GetHeaderStyle(Theme theme) { + string[] headerScopes = ["markup.heading.table"]; + Style? baseStyle = TokenProcessor.GetStyleForScopes(headerScopes, theme); + Color fgColor = baseStyle?.Foreground ?? Color.Yellow; + Color? bgColor = baseStyle?.Background; + Decoration decoration = (baseStyle is not null ? baseStyle.Decoration : Decoration.None) | Decoration.Bold; + return new Style(fgColor, bgColor, decoration); + } + + /// + /// Gets the cell style for table data cells. + /// + private static Style GetCellStyle(Theme theme) { + string[] cellScopes = ["markup.table.cell"]; + Style? baseStyle = TokenProcessor.GetStyleForScopes(cellScopes, theme); + Color fgColor = baseStyle?.Foreground ?? Color.White; + Color? bgColor = baseStyle?.Background; + Decoration decoration = baseStyle?.Decoration ?? Decoration.None; + return new Style(fgColor, bgColor, decoration); + } +} diff --git a/src/Utilities/AssemblyInfo.cs b/src/Utilities/AssemblyInfo.cs new file mode 100644 index 0000000..1febb66 --- /dev/null +++ b/src/Utilities/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PSTextMate.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Utilities/Completers.cs b/src/Utilities/Completers.cs new file mode 100644 index 0000000..d70283e --- /dev/null +++ b/src/Utilities/Completers.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Text.RegularExpressions; + +namespace PSTextMate; + +/// +/// Argument completer for TextMate language IDs and file extensions in PowerShell commands. +/// +public sealed class LanguageCompleter : IArgumentCompleter { + /// + /// Offers completion for both TextMate language ids and file extensions. + /// Examples: "powershell", "csharp", ".md", "md", ".ps1", "ps1". + /// + public IEnumerable CompleteArgument( + string commandName, + string parameterName, + string wordToComplete, + CommandAst commandAst, + IDictionary fakeBoundParameters) { + string input = wordToComplete ?? string.Empty; + bool wantsExtensionsOnly = input.Length > 0 && input[0] == '.'; + + // Prefer wildcard matching semantics; fall back to prefix/contains when empty + WildcardPattern? pattern = null; + if (!string.IsNullOrEmpty(input)) { + // Add trailing * if not already present to make incremental typing friendly + string normalized = input[^1] == '*' ? input : input + "*"; + pattern = new WildcardPattern(normalized, WildcardOptions.IgnoreCase); + } + + bool Match(string token) { + if (pattern is null) return true; // no filter + if (pattern.IsMatch(token)) return true; + // Also test without a leading dot to match bare extensions like "ps1" against ".ps1" + return token.StartsWith('.') && pattern.IsMatch(token[1..]); + } + + // Build suggestions + var results = new List(); + + if (!wantsExtensionsOnly) { + // Languages first + foreach (string lang in TextMateHelper.Languages ?? []) { + if (!Match(lang)) continue; + results.Add(new CompletionResult( + completionText: lang, + listItemText: lang, + resultType: CompletionResultType.ParameterValue, + toolTip: "TextMate language")); + } + } + + // Extensions (always include if requested or no leading '.') + foreach (string ext in TextMateHelper.Extensions ?? []) { + if (!Match(ext)) continue; + string completion = ext; // keep dot in completion + string display = ext; + results.Add(new CompletionResult( + completionText: completion, + listItemText: display, + resultType: CompletionResultType.ParameterValue, + toolTip: "File extension")); + } + + // De-duplicate (in case of overlaps) and sort: languages first, then extensions, each alphabetically + return results + .GroupBy(r => r.CompletionText, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderByDescending(r => r.ToolTip.Equals("TextMate language", StringComparison.Ordinal)) + .ThenBy(r => r.CompletionText, StringComparer.OrdinalIgnoreCase); + } +} +/// +/// Provides validation for TextMate language IDs in parameter validation attributes. +/// +public class TextMateLanguages : IValidateSetValuesGenerator { + /// + /// Returns the list of all valid TextMate language IDs for parameter validation. + /// + /// Array of supported language identifiers + public string[] GetValidValues() => TextMateHelper.Languages; + /// + /// Checks if a language ID is supported by TextMate. + /// + /// Language ID to validate + /// True if the language is supported, false otherwise + public static bool IsSupportedLanguage(string language) => TextMateHelper.Languages.Contains(language); +} +/// +/// Provides validation for file extensions in parameter validation attributes. +/// +public class TextMateExtensions : IValidateSetValuesGenerator { + /// + /// Returns the list of all valid file extensions for parameter validation. + /// + /// Array of supported file extensions + public string[] GetValidValues() => TextMateHelper.Extensions; + /// + /// Checks if a file extension is supported by TextMate. + /// + /// File extension to validate (with or without dot) + /// True if the extension is supported, false otherwise + public static bool IsSupportedExtension(string extension) => TextMateHelper.Extensions?.Contains(extension) == true; + /// + /// Checks if a file has a supported extension. + /// + /// File path to check + /// True if the file has a supported extension, false otherwise + public static bool IsSupportedFile(string file) { + string ext = Path.GetExtension(file); + return TextMateHelper.Extensions?.Contains(ext) == true; + } + +} +/// +/// Argument transformer that normalizes file extensions to include a leading dot. +/// +public class TextMateExtensionTransform : ArgumentTransformationAttribute { + /// + /// Transforms an extension to include a leading dot if missing. + /// + /// PowerShell engine intrinsics + /// Input string representing a file extension + /// Normalized extension with leading dot + public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) { + return inputData is string input + ? (object)(input.StartsWith('.') ? input : '.' + input) + : throw new ArgumentException("Input must be a string representing a file extension., '.ext' format expected.", nameof(inputData)); + } + +} diff --git a/src/Utilities/Helpers.cs b/src/Utilities/Helpers.cs new file mode 100644 index 0000000..ac364e5 --- /dev/null +++ b/src/Utilities/Helpers.cs @@ -0,0 +1,63 @@ +using TextMateSharp.Grammars; + +namespace PSTextMate; + +/// +/// Provides utility methods for accessing available TextMate languages and file extensions. +/// +public static class TextMateHelper { + /// + /// Array of supported file extensions (e.g., ".ps1", ".md", ".cs"). + /// + public static readonly string[] Extensions; + /// + /// Array of supported TextMate language identifiers (e.g., "powershell", "markdown", "csharp"). + /// + public static readonly string[] Languages; + /// + /// List of all available language definitions with metadata. + /// + public static readonly List AvailableLanguages; + static TextMateHelper() { + try { + RegistryOptions _registryOptions = new(ThemeName.DarkPlus); + AvailableLanguages = _registryOptions.GetAvailableLanguages(); + + // 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); + } + } + internal static string[] NormalizeToLines(List buffer) { + if (buffer.Count == 0) { + return []; + } + + // Multiple strings in buffer - treat each as a line + if (buffer.Count > 1) { + return [.. buffer]; + } + + // Single string - check if it contains newlines + string? single = buffer[0]; + if (string.IsNullOrEmpty(single)) { + return single is not null ? [single] : []; + } + + // Split on newlines if present + if (single.Contains('\n') || single.Contains('\r')) { + return single.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + } + + // Single string with no newlines + return [single]; + } +} diff --git a/src/Utilities/ITextMateStyler.cs b/src/Utilities/ITextMateStyler.cs new file mode 100644 index 0000000..47c9cca --- /dev/null +++ b/src/Utilities/ITextMateStyler.cs @@ -0,0 +1,27 @@ +using Spectre.Console; +using TextMateSharp.Themes; + +namespace PSTextMate.Core; + +/// +/// Abstraction for applying TextMate token styles to text. +/// Enables reuse of TextMate highlighting in different contexts +/// (code blocks, inline code, etc). +/// +public interface ITextMateStyler { + /// + /// Gets the Spectre Style for a token's scope hierarchy. + /// + /// Token scope hierarchy + /// Theme for color lookup + /// Spectre Style or null if no style found + Style? GetStyleForScopes(IEnumerable scopes, Theme theme); + + /// + /// Applies a style to text. + /// + /// Text to style + /// Style to apply (can be null) + /// Rendered text with style applied + Text ApplyStyle(string text, Style? style); +} diff --git a/src/Helpers/ImageFile.cs b/src/Utilities/ImageFile.cs similarity index 68% rename from src/Helpers/ImageFile.cs rename to src/Utilities/ImageFile.cs index a7755f0..0de7ccf 100644 --- a/src/Helpers/ImageFile.cs +++ b/src/Utilities/ImageFile.cs @@ -1,4 +1,4 @@ -// class to normalize image file path/url/base64, basically any image source that is allowed in markdown. +// class to normalize image file path/url/base64, basically any image source that is allowed in markdown. // if it is something Spectre.Console.SixelImage(string filename, bool animations) cannot handle we need to fix that, like downloading to a temporary file or converting the base64 to a file.. using System; @@ -8,57 +8,54 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace PwshSpectreConsole.TextMate.Core.Helpers; +namespace PSTextMate.Utilities; /// /// Normalizes various image sources (file paths, URLs, base64) into file paths that can be used by Spectre.Console.SixelImage. /// -internal static class ImageFile -{ +internal static partial class ImageFile { private static readonly HttpClient HttpClient = new(); - private static readonly Regex Base64Regex = new(@"^data:image\/(?[a-zA-Z]+);base64,(?[A-Za-z0-9+/=]+)$", RegexOptions.Compiled); + + [GeneratedRegex(@"^data:image\/(?[a-zA-Z]+);base64,(?[A-Za-z0-9+/=]+)$", RegexOptions.Compiled)] + private static partial Regex Base64Regex(); /// /// Normalizes an image source to a local file path that can be used by SixelImage. /// /// The image source (file path, URL, or base64 data URI) + /// Optional base directory for resolving relative paths (defaults to current directory) /// A local file path, or null if the image cannot be processed - public static async Task NormalizeImageSourceAsync(string imageSource) - { - if (string.IsNullOrWhiteSpace(imageSource)) - { + public static async Task NormalizeImageSourceAsync(string imageSource, string? baseDirectory = null) { + if (string.IsNullOrWhiteSpace(imageSource)) { return null; } // Check if it's a base64 data URI - Match base64Match = Base64Regex.Match(imageSource); - if (base64Match.Success) - { + Match base64Match = Base64Regex().Match(imageSource); + if (base64Match.Success) { return await ConvertBase64ToFileAsync(base64Match.Groups["type"].Value, base64Match.Groups["data"].Value); } // Check if it's a URL if (Uri.TryCreate(imageSource, UriKind.Absolute, out Uri? uri) && - (uri.Scheme == "http" || uri.Scheme == "https")) - { + (uri.Scheme == "http" || uri.Scheme == "https")) { return await DownloadImageToTempFileAsync(uri); } // Check if it's a local file path - if (File.Exists(imageSource)) - { + if (File.Exists(imageSource)) { return imageSource; } // Try to resolve relative paths - string currentDirectory = Environment.CurrentDirectory; - string fullPath = Path.GetFullPath(Path.Combine(currentDirectory, imageSource)); - if (File.Exists(fullPath)) - { - return fullPath; - } + // Use provided baseDirectory or fall back to current directory + string resolveBasePath = baseDirectory ?? Environment.CurrentDirectory; + string fullPath = Path.GetFullPath(Path.Combine(resolveBasePath, imageSource)); + + // Debug: For troubleshooting, we can add logging here if needed + // System.Diagnostics.Debug.WriteLine($"Resolving '{imageSource}' with base '{resolveBasePath}' -> '{fullPath}' (exists: {File.Exists(fullPath)})"); - return null; + return File.Exists(fullPath) ? fullPath : null; } /// @@ -67,35 +64,28 @@ internal static class ImageFile /// The image type (e.g., "png", "jpg") /// The base64 encoded image data /// Path to the temporary file, or null if conversion fails - private static async Task ConvertBase64ToFileAsync(string imageType, string base64Data) - { - try - { + private static async Task ConvertBase64ToFileAsync(string imageType, string base64Data) { + try { byte[] imageBytes = Convert.FromBase64String(base64Data); string tempFileName = Path.Combine(Path.GetTempPath(), $"pstextmate_img_{Guid.NewGuid():N}.{imageType}"); await File.WriteAllBytesAsync(tempFileName, imageBytes); // Schedule cleanup after a reasonable time (1 hour) - _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => - { - try - { - if (File.Exists(tempFileName)) - { + _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => { + try { + if (File.Exists(tempFileName)) { File.Delete(tempFileName); } } - catch - { + catch { // Ignore cleanup errors } }); return tempFileName; } - catch - { + catch { return null; } } @@ -105,20 +95,17 @@ internal static class ImageFile /// /// The image URL /// Path to the temporary file, or null if download fails - private static async Task DownloadImageToTempFileAsync(Uri imageUri) - { - try - { + private static async Task DownloadImageToTempFileAsync(Uri imageUri) { + try { using HttpResponseMessage response = await HttpClient.GetAsync(imageUri); - if (!response.IsSuccessStatusCode) - { + if (!response.IsSuccessStatusCode) { return null; } string? contentType = response.Content.Headers.ContentType?.MediaType; string extension = GetExtensionFromContentType(contentType) ?? - Path.GetExtension(imageUri.LocalPath) ?? - ".img"; + Path.GetExtension(imageUri.LocalPath) ?? + ".img"; string tempFileName = Path.Combine(Path.GetTempPath(), $"pstextmate_img_{Guid.NewGuid():N}{extension}"); @@ -126,25 +113,20 @@ internal static class ImageFile await response.Content.CopyToAsync(fileStream); // Schedule cleanup after a reasonable time (1 hour) - _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => - { - try - { - if (File.Exists(tempFileName)) - { + _ = Task.Delay(TimeSpan.FromHours(1)).ContinueWith(_ => { + try { + if (File.Exists(tempFileName)) { File.Delete(tempFileName); } } - catch - { + catch { // Ignore cleanup errors } }); return tempFileName; } - catch - { + catch { return null; } } @@ -154,10 +136,8 @@ internal static class ImageFile /// /// The MIME content type /// The appropriate file extension - private static string? GetExtensionFromContentType(string? contentType) - { - return contentType?.ToLowerInvariant() switch - { + private static string? GetExtensionFromContentType(string? contentType) { + return contentType?.ToLowerInvariant() switch { "image/jpeg" => ".jpg", "image/jpg" => ".jpg", "image/png" => ".png", @@ -175,35 +155,30 @@ internal static class ImageFile /// /// The image source to check /// True if the image source is likely supported - public static bool IsLikelySupportedImageFormat(string imageSource) - { - if (string.IsNullOrWhiteSpace(imageSource)) - { + public static bool IsLikelySupportedImageFormat(string imageSource) { + if (string.IsNullOrWhiteSpace(imageSource)) { return false; } // Check for supported extensions string extension = Path.GetExtension(imageSource).ToLowerInvariant(); - string[] supportedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + string[] supportedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]; - if (supportedExtensions.Contains(extension)) - { + if (supportedExtensions.Contains(extension)) { return true; } // Check for base64 data URI with supported format - Match base64Match = Base64Regex.Match(imageSource); - if (base64Match.Success) - { + Match base64Match = Base64Regex().Match(imageSource); + if (base64Match.Success) { string imageType = base64Match.Groups["type"].Value.ToLowerInvariant(); - string[] supportedTypes = new[] { "jpg", "jpeg", "png", "gif", "bmp", "webp" }; + string[] supportedTypes = ["jpg", "jpeg", "png", "gif", "bmp", "webp"]; return supportedTypes.Contains(imageType); } // For URLs, check the extension in the URL path if (Uri.TryCreate(imageSource, UriKind.Absolute, out Uri? uri) && - (uri.Scheme == "http" || uri.Scheme == "https")) - { + (uri.Scheme == "http" || uri.Scheme == "https")) { string urlExtension = Path.GetExtension(uri.LocalPath).ToLowerInvariant(); return supportedExtensions.Contains(urlExtension); } diff --git a/src/Utilities/InlineTextExtractor.cs b/src/Utilities/InlineTextExtractor.cs new file mode 100644 index 0000000..e4edabb --- /dev/null +++ b/src/Utilities/InlineTextExtractor.cs @@ -0,0 +1,58 @@ +using System.Text; +using Markdig.Syntax.Inlines; + +namespace PSTextMate.Utilities; + +/// +/// Utility for extracting plain text from Markdig inline elements. +/// Consolidates text extraction logic used across multiple renderers. +/// +internal static class InlineTextExtractor { + /// + /// Recursively extracts plain text from inline elements. + /// + /// The inline element to extract text from + /// StringBuilder to append extracted text to + public static void ExtractText(Inline inline, StringBuilder builder) { + switch (inline) { + case LiteralInline literal: + builder.Append(literal.Content.ToString()); + break; + + case ContainerInline container: + foreach (Inline child in container) { + ExtractText(child, builder); + } + break; + + case LeafInline leaf when leaf is CodeInline code: + builder.Append(code.Content); + break; + + default: + break; + } + } + + /// + /// Extracts all text from an inline container into a single string. + /// + /// The container to extract from + /// Extracted plain text + public static string ExtractAllText(ContainerInline? inlineContainer) { + if (inlineContainer is null) { + return string.Empty; + } + + StringBuilder builder = StringBuilderPool.Rent(); + try { + foreach (Inline inline in inlineContainer) { + ExtractText(inline, builder); + } + return builder.ToString(); + } + finally { + StringBuilderPool.Return(builder); + } + } +} diff --git a/src/Utilities/MarkdownPatterns.cs b/src/Utilities/MarkdownPatterns.cs new file mode 100644 index 0000000..8dc175e --- /dev/null +++ b/src/Utilities/MarkdownPatterns.cs @@ -0,0 +1,41 @@ +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace PSTextMate.Utilities; + +/// +/// Utility for detecting common markdown patterns like standalone images. +/// Consolidates pattern detection logic used across multiple renderers. +/// +internal static class MarkdownPatterns { + /// + /// Checks if a paragraph block contains only a single image (no other text). + /// Used to apply special rendering or spacing for standalone images. + /// + /// The paragraph block to check + /// True if paragraph contains only an image, false otherwise + public static bool IsStandaloneImage(ParagraphBlock paragraph) { + if (paragraph.Inline is null) { + return false; + } + + // Check if the paragraph contains only one LinkInline with IsImage = true + var inlines = paragraph.Inline.ToList(); + + // Single image case + if (inlines.Count == 1 && inlines[0] is LinkInline link && link.IsImage) { + return true; + } + + // Sometimes there might be whitespace inlines around the image + // Filter out empty/whitespace literals + var nonWhitespace = inlines + .Where(i => i is not LineBreakInline && + !(i is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) + .ToList(); + + return nonWhitespace.Count == 1 + && nonWhitespace[0] is LinkInline imageLink + && imageLink.IsImage; + } +} diff --git a/src/Utilities/SpectreTextMateStyler.cs b/src/Utilities/SpectreTextMateStyler.cs new file mode 100644 index 0000000..f091585 --- /dev/null +++ b/src/Utilities/SpectreTextMateStyler.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Spectre.Console; +using TextMateSharp.Themes; + +namespace PSTextMate.Core; + +/// +/// Spectre.Console implementation of ITextMateStyler. +/// Caches Style objects to avoid repeated creation. +/// +internal class SpectreTextMateStyler : ITextMateStyler { + /// + /// Cache: (scopesKey, themeHash) → Style + /// + private readonly ConcurrentDictionary<(string scopesKey, int themeHash), Style?> + _styleCache = new(); + + public Style? GetStyleForScopes(IEnumerable scopes, Theme theme) { + if (scopes == null) + return null; + + // Create cache key from scopes and theme instance + string scopesKey = string.Join(",", scopes); + int themeHash = RuntimeHelpers.GetHashCode(theme); + (string scopesKey, int themeHash) cacheKey = (scopesKey, themeHash); + + // Return cached style or compute new one + return _styleCache.GetOrAdd(cacheKey, _ => ComputeStyle(scopes, theme)); + } + + public Text ApplyStyle(string text, Style? style) + => string.IsNullOrEmpty(text) ? Text.Empty : new Text(text, style ?? Style.Plain); + + /// + /// Computes the Spectre Style for a scope hierarchy by looking up theme rules. + /// Follows same pattern as TokenProcessor.GetStyleForScopes for consistency. + /// + private static Style? ComputeStyle(IEnumerable scopes, Theme theme) { + // Convert to list if not already (theme.Match expects IList) + IList scopesList = scopes as IList ?? [.. scopes]; + + int foreground = -1; + int background = -1; + FontStyle fontStyle = FontStyle.NotSet; + + // Match all applicable theme rules for this scope hierarchy + foreach (ThemeTrieElementRule? rule in theme.Match(scopesList)) { + if (foreground == -1 && rule.foreground > 0) + foreground = rule.foreground; + if (background == -1 && rule.background > 0) + background = rule.background; + if (fontStyle == FontStyle.NotSet && rule.fontStyle > 0) + fontStyle = rule.fontStyle; + } + + // No matching rules found + if (foreground == -1 && background == -1 && fontStyle == FontStyle.NotSet) + return null; + + // Use StyleHelper for consistent color and decoration conversion + Color? foregroundColor = StyleHelper.GetColor(foreground, theme); + Color? backgroundColor = StyleHelper.GetColor(background, theme); + Decoration decoration = StyleHelper.GetDecoration(fontStyle); + + return new Style(foregroundColor, backgroundColor, decoration); + } +} diff --git a/src/Utilities/StringBuilderExtensions.cs b/src/Utilities/StringBuilderExtensions.cs new file mode 100644 index 0000000..0027f27 --- /dev/null +++ b/src/Utilities/StringBuilderExtensions.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Text; +using Spectre.Console; + +namespace PSTextMate.Utilities; + +/// +/// 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; + } + /// + /// Appends an integer value with optional style using invariant culture formatting. + /// + /// StringBuilder to append to + /// Optional style to apply + /// Nullable integer to append + /// The same StringBuilder for method chaining + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, int? value) + => AppendWithStyle(builder, style, value?.ToString(CultureInfo.InvariantCulture)); + + /// + /// Appends a string value with optional style markup, escaping special characters. + /// + /// StringBuilder to append to + /// Optional style to apply + /// String text to append + /// The same StringBuilder for method chaining + public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) { + value ??= string.Empty; + return style is not null + ? builder.Append('[') + .Append(style.ToMarkup()) + .Append(']') + .Append(value.EscapeMarkup()) + .Append("[/]") + : builder.Append(value); + } + + /// + /// Appends a string value with optional style markup and space separator, escaping special characters. + /// + /// StringBuilder to append to + /// Optional style to apply + /// String text to append + /// The same StringBuilder for method chaining + public static StringBuilder AppendWithStyleN(this StringBuilder builder, Style? style, string? value) { + value ??= string.Empty; + return style is not null + ? builder.Append('[') + .Append(style.ToMarkup()) + .Append(']') + .Append(value) + .Append("[/] ") + : 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) { + return style is not null + ? builder.Append('[') + .Append(style.ToMarkup()) + .Append(']') + .Append(value) + .Append("[/]") + : builder.Append(value); + } +} diff --git a/src/Utilities/StringBuilderPool.cs b/src/Utilities/StringBuilderPool.cs new file mode 100644 index 0000000..2317a2e --- /dev/null +++ b/src/Utilities/StringBuilderPool.cs @@ -0,0 +1,16 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace PSTextMate.Utilities; + +internal static class StringBuilderPool { + private static readonly ConcurrentBag _bag = []; + + public static StringBuilder Rent() => _bag.TryTake(out StringBuilder? sb) ? sb : new StringBuilder(); + + public static void Return(StringBuilder sb) { + if (sb is null) return; + sb.Clear(); + _bag.Add(sb); + } +} diff --git a/src/Extensions/SpanOptimizedStringExtensions.cs b/src/Utilities/StringExtensions.cs similarity index 59% rename from src/Extensions/SpanOptimizedStringExtensions.cs rename to src/Utilities/StringExtensions.cs index b6fa1c8..89c200d 100644 --- a/src/Extensions/SpanOptimizedStringExtensions.cs +++ b/src/Utilities/StringExtensions.cs @@ -1,13 +1,48 @@ using System.Text; -namespace PwshSpectreConsole.TextMate.Extensions; +namespace PSTextMate.Utilities; /// -/// Enhanced string manipulation methods optimized with Span operations. -/// Provides significant performance improvements for text processing scenarios. +/// Provides optimized string manipulation methods using modern .NET performance patterns. +/// Uses Span and ReadOnlySpan to minimize memory allocations during text processing. /// -public static class SpanOptimizedStringExtensions -{ +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 SpanSubstring(this string source, int startIndex, int endIndex) { + return startIndex < 0 || endIndex > source.Length || startIndex > endIndex + ? [] + : 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.SpanSubstring(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) + => strings.All(string.IsNullOrEmpty); + /// /// Joins string arrays using span operations for better performance. /// Avoids multiple string allocations during concatenation. @@ -15,8 +50,7 @@ public static class SpanOptimizedStringExtensions /// Array of strings to join /// Separator character /// Joined string - public static string JoinOptimized(this string[] values, char separator) - { + public static string SpanJoin(this string[] values, char separator) { if (values.Length == 0) return string.Empty; if (values.Length == 1) return values[0] ?? string.Empty; @@ -27,8 +61,7 @@ public static string JoinOptimized(this string[] values, char separator) var builder = new StringBuilder(totalLength); - for (int i = 0; i < values.Length; i++) - { + for (int i = 0; i < values.Length; i++) { if (i > 0) builder.Append(separator); if (values[i] is not null) builder.Append(values[i].AsSpan()); @@ -37,36 +70,6 @@ public static string JoinOptimized(this string[] values, char separator) 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. @@ -76,8 +79,7 @@ public static string JoinOptimized(this string[] values, string separator) /// String split options /// Maximum expected number of splits for optimization /// Array of split strings - public static string[] SplitOptimized(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) - { + public static string[] SpanSplit(this string source, char[] separators, StringSplitOptions options = StringSplitOptions.None, int maxSplits = 16) { if (string.IsNullOrEmpty(source)) return []; @@ -86,17 +88,14 @@ public static string[] SplitOptimized(this string source, char[] separators, Str var results = new List(Math.Min(maxSplits, 64)); // Cap initial capacity int start = 0; - for (int i = 0; i <= sourceSpan.Length; i++) - { + for (int i = 0; i <= sourceSpan.Length; i++) { bool isSeparator = i < sourceSpan.Length && separators.Contains(sourceSpan[i]); bool isEnd = i == sourceSpan.Length; - if (isSeparator || isEnd) - { + if (isSeparator || isEnd) { ReadOnlySpan segment = sourceSpan[start..i]; - if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) - { + if (options.HasFlag(StringSplitOptions.RemoveEmptyEntries) && segment.IsEmpty) { start = i + 1; continue; } @@ -109,7 +108,7 @@ public static string[] SplitOptimized(this string source, char[] separators, Str } } - return results.ToArray(); + return [.. results]; } /// @@ -118,8 +117,7 @@ public static string[] SplitOptimized(this string source, char[] separators, Str /// /// Source string to trim /// Trimmed string - public static string TrimOptimized(this string source) - { + public static string SpanTrim(this string source) { if (string.IsNullOrEmpty(source)) return source ?? string.Empty; @@ -133,13 +131,8 @@ public static string TrimOptimized(this string source) /// Source string to search /// Characters to search for /// True if any character is found - public static bool ContainsAnyOptimized(this string source, ReadOnlySpan chars) - { - if (string.IsNullOrEmpty(source) || chars.IsEmpty) - return false; - - return source.AsSpan().IndexOfAny(chars) >= 0; - } + public static bool SpanContainsAny(this string source, ReadOnlySpan chars) + => !string.IsNullOrEmpty(source) && !chars.IsEmpty && source.AsSpan().IndexOfAny(chars) >= 0; /// /// Replaces characters in a string using span operations for better performance. @@ -148,8 +141,7 @@ public static bool ContainsAnyOptimized(this string source, ReadOnlySpan c /// Character to replace /// Replacement character /// String with replacements - public static string ReplaceOptimized(this string source, char oldChar, char newChar) - { + public static string SpanReplace(this string source, char oldChar, char newChar) { if (string.IsNullOrEmpty(source)) return source ?? string.Empty; @@ -163,8 +155,7 @@ public static string ReplaceOptimized(this string source, char oldChar, char new var result = new StringBuilder(source.Length); int lastIndex = 0; - do - { + do { result.Append(sourceSpan[lastIndex..firstIndex]); result.Append(newChar); lastIndex = firstIndex + 1; diff --git a/src/Helpers/TextMateResolver.cs b/src/Utilities/TextMateResolver.cs similarity index 71% rename from src/Helpers/TextMateResolver.cs rename to src/Utilities/TextMateResolver.cs index 3a827e8..5bdb41c 100644 --- a/src/Helpers/TextMateResolver.cs +++ b/src/Utilities/TextMateResolver.cs @@ -1,12 +1,11 @@ -using System; +using System; -namespace PwshSpectreConsole.TextMate; +namespace PSTextMate; /// /// Resolves a user-provided token into either a TextMate language id or a file extension. /// -internal static class TextMateResolver -{ +internal static class TextMateResolver { /// /// Resolve a grammar token that may be a language id or a file extension. /// Heuristics: @@ -14,21 +13,17 @@ internal static class TextMateResolver /// - If known TextMate language id, treat as language /// - Otherwise treat as extension (allow values like 'ps1', 'md') /// - public static (string token, bool asExtension) ResolveToken(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { + public static (string token, bool asExtension) ResolveToken(string value) { + if (string.IsNullOrWhiteSpace(value)) { return ("powershell", false); } string v = value.Trim(); - if (v.StartsWith('.')) - { + if (v.StartsWith('.')) { return (v, true); } - if (TextMateLanguages.IsSupportedLanguage(v)) - { + if (TextMateLanguages.IsSupportedLanguage(v)) { return (v, false); } diff --git a/src/Extensions/ThemeExtensions.cs b/src/Utilities/ThemeExtensions.cs similarity index 76% rename from src/Extensions/ThemeExtensions.cs rename to src/Utilities/ThemeExtensions.cs index de2a84d..f908a47 100644 --- a/src/Extensions/ThemeExtensions.cs +++ b/src/Utilities/ThemeExtensions.cs @@ -1,20 +1,20 @@ -using Spectre.Console; -using PwshSpectreConsole.TextMate.Core; +using PSTextMate.Core; +using Spectre.Console; using TextMateSharp.Themes; -namespace PwshSpectreConsole.TextMate.Extensions; -public static class ThemeExtensions -{ +namespace PSTextMate.Utilities; + +/// +/// Extension methods for converting TextMate themes and colors to Spectre.Console styling. +/// +public static class ThemeExtensions { /// /// Converts a TextMate theme to a Spectre.Console style. /// This is a placeholder - actual theming should be done via scope-based lookups. /// /// The TextMate theme to convert. /// A Spectre.Console style representing the TextMate theme. - public static Style ToSpectreStyle(this Theme theme) - { - return new Style(foreground: Color.Default, background: Color.Default); - } + public static Style ToSpectreStyle(this Theme theme) => new(foreground: Color.Default, background: Color.Default); /// /// Converts a TextMate color to a Spectre.Console color. /// @@ -22,16 +22,12 @@ public static Style ToSpectreStyle(this Theme theme) /// A Spectre.Console color representing the TextMate color. // Try to use a more general color type, e.g. System.Drawing.Color or a custom struct/class // If theme.Foreground and theme.Background are strings (hex), parse them accordingly - public static Color ToSpectreColor(this object color) - { - if (color is string hex && !string.IsNullOrWhiteSpace(hex)) - { - try - { + public static Color ToSpectreColor(this object color) { + if (color is string hex && !string.IsNullOrWhiteSpace(hex)) { + try { return StyleHelper.HexToColor(hex); } - catch - { + catch { return Color.Default; } } @@ -43,8 +39,7 @@ public static Color ToSpectreColor(this object color) /// The TextMate font style to convert. /// A Spectre.Console font style representing the TextMate font style. - public static FontStyle ToSpectreFontStyle(this FontStyle fontStyle) - { + public static FontStyle ToSpectreFontStyle(this FontStyle fontStyle) { FontStyle result = FontStyle.None; if ((fontStyle & FontStyle.Italic) != 0) result |= FontStyle.Italic; diff --git a/src/Utilities/TokenStyleProcessor.cs b/src/Utilities/TokenStyleProcessor.cs new file mode 100644 index 0000000..1010fe0 --- /dev/null +++ b/src/Utilities/TokenStyleProcessor.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Spectre.Console; +using Spectre.Console.Rendering; +using TextMateSharp.Grammars; +using TextMateSharp.Themes; + +namespace PSTextMate.Core; + +/// +/// Processes tokens and applies TextMate styling to produce Spectre renderables. +/// Decoupled from specific rendering context (can be used in code blocks, inline code, etc). +/// +internal static class TokenStyleProcessor { + /// + /// Processes tokens from a single line and produces styled Text objects. + /// + /// Tokens from grammar tokenization + /// Source line text + /// Theme for color lookup + /// Styler instance (inject for testability) + /// Array of styled Text renderables + public static IRenderable[] ProcessTokens( + IToken[] tokens, + string line, + Theme theme, + ITextMateStyler styler) { + var result = new List(); + + foreach (IToken token in tokens) { + int startIndex = Math.Min(token.StartIndex, line.Length); + int endIndex = Math.Min(token.EndIndex, line.Length); + + // Skip empty tokens + if (startIndex >= endIndex) + continue; + + // Extract text + string tokenText = line[startIndex..endIndex]; + + // Get style for this token's scopes + Style? style = styler.GetStyleForScopes(token.Scopes, theme); + + // Apply style and add to result + result.Add(styler.ApplyStyle(tokenText, style)); + } + + return [.. result]; + } + + /// + /// Process multiple lines of tokens and return combined renderables. + /// + public static IRenderable[] ProcessLines( + (IToken[] tokens, string line)[] tokenizedLines, + Theme theme, + ITextMateStyler styler) { + var result = new List(); + + foreach ((IToken[] tokens, string line) in tokenizedLines) { + // Process each line + IRenderable[] lineRenderables = ProcessTokens(tokens, line, theme, styler); + + // Wrap line's tokens in a Row + if (lineRenderables.Length > 0) + result.Add(new Rows(lineRenderables)); + else + result.Add(Text.Empty); + } + + return [.. result]; + } +} diff --git a/src/Utilities/VTConversion.cs b/src/Utilities/VTConversion.cs new file mode 100644 index 0000000..587a4df --- /dev/null +++ b/src/Utilities/VTConversion.cs @@ -0,0 +1,600 @@ +using System.Runtime.CompilerServices; +using System.Text; +using Spectre.Console; + +namespace PSTextMate.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'; + + /// + /// Parses a string containing VT escape sequences and returns a Paragraph object. + /// Optimized single-pass streaming implementation that avoids intermediate collections. + /// This is more efficient than ToMarkup() as it directly constructs the Paragraph + /// without intermediate markup string generation, parsing, or segment collection. + /// + /// 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(); + + var paragraph = new Paragraph(); + 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) { + // Append text segment before escape sequence + if (i > textStart) { + string text = input[textStart..i]; + if (currentStyle.HasAnyStyle) { + paragraph.Append(text, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(text, Style.Plain); + } + } + + // 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) { + // Append text segment before OSC sequence + if (i > textStart) { + string text = input[textStart..i]; + if (currentStyle.HasAnyStyle) { + paragraph.Append(text, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(text, Style.Plain); + } + } + + // 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)) { + if (currentStyle.HasAnyStyle) { + paragraph.Append(oscResult.LinkText, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(oscResult.LinkText, Style.Plain); + } + } + i = oscResult.End; + textStart = i; + } + else { + i++; + } + } + else { + i++; + } + } + else { + i++; + } + } + + // Append remaining text + if (textStart < span.Length) { + string text = input[textStart..]; + if (currentStyle.HasAnyStyle) { + paragraph.Append(text, currentStyle.ToSpectreStyle()); + } + else { + paragraph.Append(text, Style.Plain); + } + } + + return paragraph; + } + + /// + /// Parses a single VT escape sequence and updates the style state. + /// Uses stack-allocated parameter array for efficient memory usage. + /// Returns the index after the escape sequence. + /// + private static int ParseEscapeSequence(ReadOnlySpan span, int start, ref StyleState style) { + int i = start + 2; // Skip ESC[ + const int MaxEscapeSequenceLength = 1024; + + // Stack-allocate parameter array (SGR sequences typically have < 16 parameters) + Span parameters = stackalloc int[16]; + int paramCount = 0; + int currentNumber = 0; + bool hasNumber = false; + int escapeLength = 0; + + // Parse parameters (numbers separated by semicolons or colons) + while (i < span.Length && span[i] != SGR_END && escapeLength < MaxEscapeSequenceLength) { + if (IsDigit(span[i])) { + // Overflow-safe parsing per XenoAtom pattern + int digit = span[i] - '0'; + if (currentNumber > (int.MaxValue - digit) / 10) { + currentNumber = int.MaxValue; // Clamp instead of overflow + } + else { + currentNumber = (currentNumber * 10) + digit; + } + hasNumber = true; + } + // Support both ; and : as separators (SGR uses : for hyperlinks) + else if (span[i] is ';' or ':') { + if (paramCount < parameters.Length) { + parameters[paramCount++] = hasNumber ? currentNumber : 0; + } + currentNumber = 0; + hasNumber = false; + } + else { + // Invalid character, abort parsing + return start + 1; + } + i++; + escapeLength++; + } + + if (i >= span.Length || span[i] != SGR_END) { + // Invalid sequence + return start + 1; + } + + // Add the last parameter + if (paramCount < parameters.Length) { + parameters[paramCount++] = hasNumber ? currentNumber : 0; + } + + // Apply SGR parameters to style (using slice of actual parameters) + ApplySgrParameters(parameters[..paramCount], ref style); + + return i + 1; // Return position after 'm' + } + + /// + /// Result of parsing an OSC sequence. + /// + private readonly struct OscResult(int end, string? linkText = null) { + public readonly int End = end; + public readonly string? LinkText = linkText; + } + + /// + /// Parses an OSC (Operating System Command) sequence and updates the style state. + /// Returns the result containing end position and any link text found. + /// Safety limits prevent memory exhaustion from malformed sequences. + /// + private static OscResult ParseOscSequence(ReadOnlySpan span, int start, ref StyleState style) { + int i = start + 2; // Skip ESC] + const int MaxOscLength = 32768; + int oscLength = 0; + + // Check if this is OSC 8 (hyperlink) + if (i < span.Length && span[i] == '8' && i + 1 < span.Length && span[i + 1] == ';') { + 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] != ';' && oscLength < MaxOscLength) { + i++; + oscLength++; + } + + if (i < span.Length && span[i] == ';') { + i++; // Skip the semicolon + oscLength++; + int urlStart = i; + + // Find the end of the URL (look for ESC\) + while (i < span.Length - 1 && oscLength < MaxOscLength) { + if (span[i] == ESC && span[i + 1] == '\\') { + urlEnd = i; + break; + } + i++; + oscLength++; + } + + if (urlEnd > urlStart && urlEnd - urlStart < MaxOscLength) { + string url = span[urlStart..urlEnd].ToString(); + i = urlEnd + 2; // Skip ESC\ + + // Check if this is a link start (has URL) or link end (empty) + if (!string.IsNullOrEmpty(url)) { + // 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 && oscLength < MaxOscLength) // Need at least 6 chars for ESC]8;;ESC\ + { + if (span[i] == ESC && span[i + 1] == OSC_START && + span[i + 2] == '8' && span[i + 3] == ';' && + span[i + 4] == ';' && span[i + 5] == ESC && + span[i + 6] == '\\') { + linkTextEnd = i; + break; + } + i++; + oscLength++; + } + + if (linkTextEnd > linkTextStart) { + string linkText = span[linkTextStart..linkTextEnd].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 && oscLength < MaxOscLength) { + if (span[i] == ESC && span[i + 1] == '\\') { + return new OscResult(i + 2); + } + i++; + oscLength++; + } + + return new OscResult(start + 1); // Failed to parse, advance by 1 + } + + /// + /// Applies SGR (Select Graphic Rendition) parameters to the style state. + /// Optimized to work with Span instead of List for zero-allocation processing. + /// + private static void ApplySgrParameters(ReadOnlySpan parameters, ref StyleState style) { + for (int i = 0; i < parameters.Length; 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.Length) { + int colorType = parameters[i + 1]; + if (colorType == 2 && i + 4 < parameters.Length) { + // RGB + byte r = (byte)Math.Clamp(parameters[i + 2], 0, 255); + byte g = (byte)Math.Clamp(parameters[i + 3], 0, 255); + byte b = (byte)Math.Clamp(parameters[i + 4], 0, 255); + style.Foreground = new Color(r, g, b); + i += 4; + } + else if (colorType == 5 && i + 2 < parameters.Length) { + // 256-color + 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.Length) { + int colorType = parameters[i + 1]; + if (colorType == 2 && i + 4 < parameters.Length) // RGB + { + byte r = (byte)Math.Clamp(parameters[i + 2], 0, 255); + byte g = (byte)Math.Clamp(parameters[i + 3], 0, 255); + byte b = (byte)Math.Clamp(parameters[i + 4], 0, 255); + style.Background = new Color(r, g, b); + i += 4; + } + else if (colorType == 5 && i + 2 < parameters.Length) // 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; + default: + 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.DarkRed, + 32 or 42 => Color.DarkGreen, + 33 or 43 => Color.Olive, + 34 or 44 => Color.DarkBlue, + 35 or 45 => Color.Purple, + 36 or 46 => Color.Teal, + 37 or 47 => Color.Silver, + 90 or 100 => Color.Grey, + 91 or 101 => Color.Red, + 92 or 102 => Color.Green, + 93 or 103 => Color.Yellow, + 94 or 104 => Color.Blue, + 95 or 105 => Color.Fuchsia, + 96 or 106 => Color.Aqua, + 97 or 107 => Color.White, + _ => Color.Default + // 30 or 40 => Color.Black, + // 31 or 41 => Color.Red, + // 32 or 42 => Color.Green, + // 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 + }; + + /// + /// Gets a Color object for 256-color palette. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Color Get256Color(int index) { + if (index is < 0 or > 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 the current style state during parsing. + /// Uses mutable fields with init properties for efficient 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 || Link is not null; + + public void Reset() { + Foreground = null; + Background = null; + Decoration = Decoration.None; + Link = null; + } + + public readonly Style ToSpectreStyle() => + new(Foreground, Background, Decoration, Link); + + public readonly string ToMarkup() { + // Use StringBuilder to avoid List allocation + // Typical markup is <64 chars, so inline capacity avoids resizing + var sb = new StringBuilder(64); + + if (Foreground.HasValue) { + sb.Append(Foreground.Value.ToMarkup()); + } + else { + sb.Append("Default "); + } + + if (Background.HasValue) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("on ").Append(Background.Value.ToMarkup()); + } + + if (Decoration != Decoration.None) { + if ((Decoration & Decoration.Bold) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("bold"); + } + if ((Decoration & Decoration.Dim) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("dim"); + } + if ((Decoration & Decoration.Italic) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("italic"); + } + if ((Decoration & Decoration.Underline) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("underline"); + } + if ((Decoration & Decoration.Strikethrough) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("strikethrough"); + } + if ((Decoration & Decoration.SlowBlink) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("slowblink"); + } + if ((Decoration & Decoration.RapidBlink) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("rapidblink"); + } + if ((Decoration & Decoration.Invert) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("invert"); + } + if ((Decoration & Decoration.Conceal) != 0) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("conceal"); + } + } + + if (!string.IsNullOrEmpty(Link)) { + if (sb.Length > 0) sb.Append(' '); + sb.Append("link=").Append(Link); + } + + return sb.ToString(); + } + } +} diff --git a/tests/Core/Markdown/MarkdownRendererTests.cs b/tests/Core/Markdown/MarkdownRendererTests.cs deleted file mode 100644 index 8ada35c..0000000 --- a/tests/Core/Markdown/MarkdownRendererTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using PwshSpectreConsole.TextMate.Core.Markdown.Renderers; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Core.Markdown; - -public class MarkdownRendererTests -{ - [Fact] - public void Render_SimpleMarkdown_ReturnsValidRows() - { - // Arrange - var markdown = "# Hello World\nThis is a test."; - var theme = CreateTestTheme(); - var themeName = ThemeName.DarkPlus; - - // Act - var result = 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 deleted file mode 100644 index 51056c1..0000000 --- a/tests/Extensions/StringBuilderExtensionsTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using PwshSpectreConsole.TextMate.Extensions; - -namespace PwshSpectreConsole.TextMate.Tests.Extensions; - -public class StringBuilderExtensionsTests -{ - [Fact] - public void AppendLink_ValidUrlAndText_GeneratesCorrectMarkup() - { - // Arrange - var builder = new StringBuilder(); - var url = "https://example.com"; - var text = "Example Link"; - - // Act - builder.AppendLink(url, text); - - // Assert - var result = builder.ToString(); - result.Should().Be("[link=https://example.com]Example Link[/]"); - } - - [Fact] - public void AppendWithStyle_NoStyle_AppendsTextOnly() - { - // Arrange - var builder = new StringBuilder(); - var text = "Hello World"; - - // Act - builder.AppendWithStyle(null, text); - - // Assert - builder.ToString().Should().Be("Hello World"); - } - - [Fact] - public void AppendWithStyle_WithStyle_GeneratesStyledMarkup() - { - // Arrange - var builder = new StringBuilder(); - var style = new Style(Color.Red, Color.Blue, Decoration.Bold); - var text = "Styled Text"; - - // Act - builder.AppendWithStyle(style, text); - - // Assert - var result = builder.ToString(); - result.Should().Contain("red"); - result.Should().Contain("blue"); - result.Should().Contain("bold"); - result.Should().Contain("Styled Text"); - result.Should().StartWith("["); - result.Should().EndWith("[/]"); - } - - [Theory] - [InlineData("")] - [InlineData(null)] - public void AppendWithStyle_NullOrEmptyText_HandlesGracefully(string? text) - { - // Arrange - var builder = new StringBuilder(); - var style = new Style(Color.Red); - - // Act - builder.AppendWithStyle(style, text); - - // Assert - var result = builder.ToString(); - result.Should().NotBeNull(); - result.Should().StartWith("["); - result.Should().EndWith("[/]"); - } - - [Fact] - public void AppendWithStyle_SpecialCharacters_EscapesMarkup() - { - // Arrange - var builder = new StringBuilder(); - var text = "[brackets] and "; - - // Act - builder.AppendWithStyle(null, text); - - // Assert - var result = builder.ToString(); - result.Should().Be("[brackets] and "); - } -} diff --git a/tests/Format-CSharp.tests.ps1 b/tests/Format-CSharp.tests.ps1 new file mode 100644 index 0000000..aee657b --- /dev/null +++ b/tests/Format-CSharp.tests.ps1 @@ -0,0 +1,35 @@ +BeforeAll { + if (-Not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + +Describe 'Format-CSharp' { + It 'Formats a simple C# string and returns renderables' { + $code = 'public class Foo { }' + $out = $code | Format-CSharp + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'public class Foo' + } + + It 'Formats a simple C# string' { + $code = 'public class Foo { }' + $out = $code | Format-CSharp + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'public class Foo' + } + + It 'Formats a C# file and returns renderables' { + $temp = Join-Path $PSScriptRoot 'temp.cs' + 'public class Temp { }' | Out-File -FilePath $temp -Encoding utf8 + try { + $out = Get-Item $temp | Format-CSharp + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'public class Temp' + } finally { + Remove-Item -Force -ErrorAction SilentlyContinue $temp + } + } +} diff --git a/tests/Format-Markdown.tests.ps1 b/tests/Format-Markdown.tests.ps1 new file mode 100644 index 0000000..abbaae5 --- /dev/null +++ b/tests/Format-Markdown.tests.ps1 @@ -0,0 +1,30 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + +Describe 'Format-Markdown' { + It 'Formats Markdown and returns renderables' { + $md = "# Title\n\nSome text" + $out = $md | Format-Markdown + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable -RenderableObject $out + $rendered | Should -Match '# Title|Title|Some text' + } + + It 'Formats Markdown' { + $md = "# Title\n\nSome text" + $out = $md | Format-Markdown + $rendered = _GetSpectreRenderable -RenderableObject $out + $rendered | Should -Match '# Title|Title|Some text' + } + + It 'Formats Markdown with Alternate and returns renderables' { + $md = "# Title\n\nSome text" + $out = $md | Format-Markdown -Alternate + $out | Should -Not -BeNullOrEmpty + $renderedAlt = _GetSpectreRenderable -RenderableObject $out + $renderedAlt | Should -Match '# Title|Title|Some text' + } +} diff --git a/tests/Format-PowerShell.tests.ps1 b/tests/Format-PowerShell.tests.ps1 new file mode 100644 index 0000000..daa2ce2 --- /dev/null +++ b/tests/Format-PowerShell.tests.ps1 @@ -0,0 +1,35 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + +Describe 'Format-PowerShell' { + It 'Formats a simple PowerShell string and returns renderables' { + $ps = 'function Test-Thing { Write-Output "hi" }' + $out = $ps | Format-PowerShell + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'function|Write-Output' + } + + It 'Formats a simple PowerShell string' { + $ps = 'function Test-Thing { Write-Output "hi" }' + $out = $ps | Format-PowerShell + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'function|Write-Output' + } + + It 'Formats a PowerShell file and returns renderables' { + $filename = Join-Path $PSScriptRoot ('{0}.ps1' -f (Get-Random)) + 'function Temp { Write-Output "ok" }' | Set-Content -Path $filename + try { + $out = Get-Item $filename | Format-PowerShell + $out | Should -Not -BeNullOrEmpty + $renderedFile = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $renderedFile | Should -Match 'function|Write-Output' + } finally { + Remove-Item -Force -ErrorAction SilentlyContinue $filename + } + } +} diff --git a/tests/Get-SupportedTextMate.tests.ps1 b/tests/Get-SupportedTextMate.tests.ps1 new file mode 100644 index 0000000..216bf8a --- /dev/null +++ b/tests/Get-SupportedTextMate.tests.ps1 @@ -0,0 +1,12 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + +Describe 'Get-SupportedTextMate' { + It 'Returns at least one available language' { + $result = Get-SupportedTextMate + $result | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/GlobalUsings.cs b/tests/GlobalUsings.cs deleted file mode 100644 index f73575b..0000000 --- a/tests/GlobalUsings.cs +++ /dev/null @@ -1,8 +0,0 @@ -global using Xunit; -global using FluentAssertions; -global using System.Text; -global using Spectre.Console; -global using TextMateSharp.Themes; -global using PwshSpectreConsole.TextMate.Core; -global using PwshSpectreConsole.TextMate.Core.Markdown; -global using PwshSpectreConsole.TextMate.Extensions; diff --git a/tests/Integration/TaskListIntegrationTests.cs b/tests/Integration/TaskListIntegrationTests.cs deleted file mode 100644 index 1fd1866..0000000 --- a/tests/Integration/TaskListIntegrationTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index b97df85..0000000 --- a/tests/Integration/TaskListReflectionRemovalTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using PwshSpectreConsole.TextMate.Core; -using TextMateSharp.Grammars; - -namespace PwshSpectreConsole.TextMate.Tests.Integration; - -/// -/// Simple tests to verify that TaskList functionality works without reflection errors. -/// These tests use the public API to ensure the reflection code has been properly removed. -/// -public class TaskListReflectionRemovalTests -{ - [Fact] - public void TextMateProcessor_MarkdownWithTaskList_ProcessesWithoutReflectionErrors() - { - // Arrange - var markdown = """ - # Task List Test - - - [x] Completed task - - [ ] Incomplete task - - [X] Another completed task - """; - - // Act & Assert - This should not throw reflection-related exceptions - var exception = Record.Exception(() => - { - var result = TextMateProcessor.ProcessLinesCodeBlock( - lines: [markdown], - themeName: ThemeName.DarkPlus, - grammarId: "markdown", - isExtension: false); - - // Verify result is not null - result.Should().NotBeNull(); - }); // Assert - No exceptions should be thrown - exception.Should().BeNull("TaskList processing should work without reflection errors"); - } - - [Theory] - [InlineData("- [x] Completed task")] - [InlineData("- [ ] Incomplete task")] - [InlineData("- [X] Uppercase completed")] - [InlineData("- Regular bullet point")] - public void TextMateProcessor_VariousListFormats_ProcessesWithoutErrors(string listItem) - { - // Arrange - var lines = new[] { "# Test", "", listItem }; - - // Act & Assert - Should not throw any exceptions - var exception = Record.Exception(() => - { - var result = TextMateProcessor.ProcessLinesCodeBlock( - lines: lines, - themeName: ThemeName.DarkPlus, - grammarId: "markdown", - isExtension: false); - - result.Should().NotBeNull(); - }); exception.Should().BeNull($"Processing list item '{listItem}' should not throw exceptions"); - } - - [Fact] - public void TextMateProcessor_ComplexMarkdownWithNestedTaskLists_ProcessesSuccessfully() - { - // Arrange - var complexMarkdown = new[] - { - "# Complex Task List Example", - "", - "## Main Tasks", - "- [x] Setup project", - " - [x] Initialize repository", - " - [x] Add .gitignore", - " - [ ] Configure CI/CD", - "", - "## Development Tasks", - "1. [x] Write core functionality", - "2. [ ] Add comprehensive tests", - " - [x] Unit tests", - " - [ ] Integration tests", - "3. [ ] Documentation", - "", - "### Code Review Checklist", - "- [X] Code follows style guidelines", - "- [ ] Tests pass", - "- [ ] Documentation updated" - }; - - // Act & Assert - This complex structure should process without any reflection errors - var exception = Record.Exception(() => - { - var result = TextMateProcessor.ProcessLinesCodeBlock( - lines: complexMarkdown, - themeName: ThemeName.DarkPlus, - grammarId: "markdown", - isExtension: false); - - result.Should().NotBeNull(); - }); exception.Should().BeNull("Complex nested TaskList processing should work without reflection"); - } -} diff --git a/tests/PSTextMate.Tests.csproj b/tests/PSTextMate.Tests.csproj deleted file mode 100644 index d1e3e10..0000000 --- a/tests/PSTextMate.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net8.0 - enable - enable - false - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - diff --git a/tests/Show-TextMate.tests.ps1 b/tests/Show-TextMate.tests.ps1 new file mode 100644 index 0000000..a12b369 --- /dev/null +++ b/tests/Show-TextMate.tests.ps1 @@ -0,0 +1,39 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + +BeforeAll { + $psString = @' +function Foo-Bar { + param([string]$Name) + Write-Host "Hello, $Name!" +} +'@ + $psowrapped = [psobject]::new($psString) + $note = [PSNoteProperty]::new('PSChildName', 'FooBar.ps1') + $psowrapped.psobject.properties.add($note) +} + +Describe 'Show-TextMate' { + It 'Formats a PSObject with PSChildName and returns rendered PowerShell output' { + $out2 = $psowrapped | Show-TextMate + $out2 | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable -RenderableObject $out2 -EscapeAnsi + $rendered | Should -Match 'FooBar|Foo-Bar' + } + It 'Formats a simple PowerShell string' { + $out = $psString | Show-TextMate + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'function|Write-Host|Foo-Bar' + } + It "Can render markdown" { + $file = Get-Item -Path (Join-Path $PSScriptRoot 'test-markdown.md') + $out = $file | Show-TextMate + $out | Should -Not -BeNullOrEmpty + $rendered = _GetSpectreRenderable -RenderableObject $out -EscapeAnsi + $rendered | Should -Match 'Markdown Test File' + $rendered | Should -Match 'Path.GetExtension' + } +} diff --git a/tests/Test-SupportedTextMate.tests.ps1 b/tests/Test-SupportedTextMate.tests.ps1 new file mode 100644 index 0000000..8fef7b8 --- /dev/null +++ b/tests/Test-SupportedTextMate.tests.ps1 @@ -0,0 +1,20 @@ +BeforeAll { + if (-not (Get-Module 'PSTextMate')) { + Import-Module (Join-Path $PSScriptRoot '..' 'output' 'PSTextMate.psd1') -ErrorAction Stop + } +} + +Describe 'Test-SupportedTextMate' { + It 'Recognizes powershell language' { + Test-SupportedTextMate -Language 'powershell' | Should -BeTrue + } + + It 'Recognizes .ps1 extension' { + Test-SupportedTextMate -Extension '.ps1' | Should -BeTrue + } + + It 'Recognizes an existing file as supported' { + $testFile = Join-Path $PSScriptRoot 'Show-TextMate.tests.ps1' + Test-SupportedTextMate -File $testFile | Should -BeTrue + } +} diff --git a/tests/test-markdown-rendering.md b/tests/test-markdown-rendering.md index 9d4ab70..a57021a 100644 --- a/tests/test-markdown-rendering.md +++ b/tests/test-markdown-rendering.md @@ -1,7 +1,5 @@ # Markdown Rendering Test File -## Code Blocks Test - ### Fenced Code Block with Language ```csharp @@ -37,19 +35,10 @@ and multiple lines - [x] Another completed task - [ ] Another incomplete task -## Headers - -# H1 Header -## H2 Header -### H3 Header -#### H4 Header -##### H5 Header -###### H6 Header - ## Paragraphs and Emphasis -This is a **bold** text and this is *italic* text. -Here's some `inline code` in a paragraph. +This is a **bold** text and this is *italic* text. +Here's some `inline code` in a paragraph. ## Tables @@ -60,17 +49,17 @@ Here's some `inline code` in a paragraph. ## Mixed Content -This paragraph contains **bold**, *italic*, and `code` elements all together. +This paragraph contains **bold**, *italic*, and `code` elements all together. ### Indented Code Block - This is an indented code block - with multiple lines - and preserved spacing + This is an indented code block + with multiple lines + and preserved spacing ## Special Characters and VT Sequences -Text with potential VT sequences: `\x1b[31mRed Text\x1b[0m` +Text with potential VT sequences: `\x1b[31mRed Text\x1b[0m` ## Edge Cases diff --git a/tests/test-markdown.md b/tests/test-markdown.md index 0a87b1a..ce8f30f 100644 --- a/tests/test-markdown.md +++ b/tests/test-markdown.md @@ -12,8 +12,6 @@ This file is for testing all supported markdown features in PSTextMate. ### Heading 3 ---- - ## Paragraphs and Line Breaks This is a paragraph with a line break. @@ -21,45 +19,38 @@ 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 + - [ ] Incomplete sub-task 1 - Unordered item 2 - Nested item - Unordered item 3 + - [x] Completed sub-item 3 1. Ordered item 1 2. Ordered item 2 1. Nested ordered item + 1. three level nest list 2 3. Ordered item 3 - [x] Completed task - [ ] Incomplete task ---- - ## Blockquote > This is a blockquote. > It can span multiple lines. ---- - ## Code Inline code: `Write-Host "Hello, World!"` @@ -75,23 +66,12 @@ Get-ChildItem $PWD ```csharp // C# code block -public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? style, string? value) -{ - value ??= string.Empty; - if (style is not null) - { - return builder.Append('[') - .Append(style.ToMarkup()) - .Append(']') - .Append(value.EscapeMarkup()) - .Append("[/]"); - } - return builder.Append(value); +public static bool IsSupportedFile(string file) { + string ext = Path.GetExtension(file); + return TextMateHelper.Extensions?.Contains(ext) == true; } ``` ---- - ## Table | Name | Value | @@ -100,14 +80,10 @@ public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? s | Beta | 2 | | Gamma | 3 | ---- - ## Images ![xkcd git](../assets/git_commit.png) ---- - ## Horizontal Rule --- @@ -116,21 +92,7 @@ public static StringBuilder AppendWithStyle(this StringBuilder builder, Style? s
This is raw HTML and may not render in all markdown processors.
---- - ## Escaped Characters \*This is not italic\* \# Not a heading - ---- - -## Second Table - -| Name | Text | -|---------|-------| -| Foo | Bar | -| Hello | World | - ---- -End of test file. diff --git a/tests/testhelper.psm1 b/tests/testhelper.psm1 new file mode 100644 index 0000000..5afb044 --- /dev/null +++ b/tests/testhelper.psm1 @@ -0,0 +1,28 @@ +function _GetSpectreRenderable { + param( + [Parameter(Mandatory)] + [object] $RenderableObject, + [switch] $EscapeAnsi + ) + try { + [Spectre.Console.Rendering.Renderable]$RenderableObject = $RenderableObject + $writer = [System.IO.StringWriter]::new() + $output = [Spectre.Console.AnsiConsoleOutput]::new($writer) + $settings = [Spectre.Console.AnsiConsoleSettings]::new() + $settings.Out = $output + $console = [Spectre.Console.AnsiConsole]::Create($settings) + $console.Write($RenderableObject) + if ($EscapeAnsi) { + return $writer.ToString() | _EscapeAnsi + } + $writer.ToString() + } + finally { + ${writer}?.Dispose() + } +} +filter _EscapeAnsi { + [System.Management.Automation.Host.PSHostUserInterface]::GetOutputString($_, $false) +} + +Export-ModuleMember -Function _GetSpectreRenderable, _EscapeAnsi diff --git a/tools/TestMeasure.ps1 b/tools/TestMeasure.ps1 new file mode 100644 index 0000000..7e8cb12 --- /dev/null +++ b/tools/TestMeasure.ps1 @@ -0,0 +1,35 @@ +if (-not (Get-Module PSTextMate)) { + # Import-Module (Join-Path $PSScriptRoot 'output' 'PSTextMate.psd1') + $Path = Resolve-Path (Join-Path $PSScriptRoot '..') + . (Join-Path $Path 'harness.ps1') -Load -Path $Path +} + +$TestStrings = @( + 'Plain ASCII', + "CJK: `u{4E2D}`u{6587}`u{65E5}`u{672C}`u{8A9E}", + "Hangul: `u{D55C}`u{AE00}", + "Emoji: `u{1F600}`u{1F64F}`u{1F680}", + "Wide + ASCII: abc`u{4E2D}def`u{1F600}ghi", + "Combining: a`u{0301} e`u{0301} n`u{0303}", + "ZWJ: `u{1F469}`u{200D}`u{1F4BB}", + "Flag: `u{1F1FA}`u{1F1F8}" +) + +$AnsiTestStrings = @( + "`e[31mRed`e[0m", + "`e[32mGreen`e[0m `e[1mBold`e[0m", + "`e[38;5;214mIndexed`e[0m", + "`e[38;2;255;128;0mTrueColor`e[0m", + "VT + Wide: `e[36m`u{4E2D}`u{6587}`e[0m", + "OSC title: `e]0;PSTextMate Test`a", + "CSI cursor move: start`e[2Cend" + '{0}{1}First - {2}{3}Second - {4}{5}{6}Bold' -f $PSStyle.Foreground.Red, $PSStyle.Background.Green, $PSStyle.Foreground.Green, $psstyle.Background.Red, $PSStyle.Blink, $PSStyle.Background.Yellow, $PSStyle.Foreground.BrightCyan + '{0}Hello{1}{2}{3}{1}{4}yep!' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.FormatHyperlink('world!', 'https://www.example.com'), [Char]::ConvertFromUtf32(128110) + '{0}Hello{1}{2}{3} https://www.example.com' -f $PSStyle.Foreground.Red, $PSStyle.Reset, $PSStyle.Background.Magenta, $PSStyle.Reset +) + +$TestStrings | Measure-String +$AnsiTestStrings| Measure-String +# $AnsiTestStrings| Measure-String -IgnoreVT +# @([string[]][System.Text.Rune[]]@(0x1F600..0x1F64F)) | Measure-String +# @([string[]][char[]]@(@(0xe0b0..0xe0d4) + @(0x2588..0x259b) + @(0x256d..0x2572))) | Measure-String diff --git a/tools/analyze-block-lines.cs b/tools/analyze-block-lines.cs new file mode 100644 index 0000000..6625432 --- /dev/null +++ b/tools/analyze-block-lines.cs @@ -0,0 +1,63 @@ +#:package Markdig.Signed@0.38.0 +using Markdig; +using Markdig.Syntax; +// dotnet run ./analyze-block-lines.cs ../tests/test-markdown.md +string path = args.Length > 0 ? args[0] : "tests/test-markdown.md"; +string markdown = File.ReadAllText(path); + +MarkdownPipeline pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseTaskLists() + .UsePipeTables() + .UseAutoLinks() + .EnableTrackTrivia() + .Build(); + +MarkdownDocument document = Markdown.Parse(markdown, pipeline); + +Console.WriteLine($"Analyzing blocks in: {path}\n"); + +Block? previousBlock = null; +int totalGapLines = 0; + +for (int i = 0; i < document.Count; i++) { + Block block = document[i]; + int endLine = GetBlockEndLine(block, markdown); + + if (previousBlock is not null) { + int prevEndLine = GetBlockEndLine(previousBlock, markdown); + int gap = block.Line - prevEndLine - 1; + totalGapLines += Math.Max(0, gap); + Console.WriteLine($" Gap: {prevEndLine} -> {block.Line} = {gap} blank lines"); + } + + Console.WriteLine($"[{i,2}] {block.GetType().Name,-20} Line {block.Line,3} -> {endLine,3} Span: {block.Span.Start}-{block.Span.End}"); + previousBlock = block; +} + +int sourceLines = markdown.Split('\n').Length; +Console.WriteLine($"\nSource lines: {sourceLines}"); +Console.WriteLine($"Document blocks: {document.Count}"); +Console.WriteLine($"Total gap lines: {totalGapLines}"); +Console.WriteLine($"Expected rendered lines (blocks + gaps): {document.Count + totalGapLines}"); + +static int GetBlockEndLine(Block block, string md) { + // For container blocks, recursively find the last child's end line + if (block is ContainerBlock container && container.Count > 0) { + return GetBlockEndLine(container[^1], md); + } + // For fenced code blocks: opening fence + content lines + closing fence + if (block is FencedCodeBlock fenced && fenced.Lines.Count > 0) { + return block.Line + fenced.Lines.Count + 1; + } + // Count newlines within the block's span (excluding the final newline which separates blocks) + // The span typically includes the trailing newline, so we stop before Span.End + int endPosition = Math.Min(block.Span.End - 1, md.Length - 1); + int newlineCount = 0; + for (int i = block.Span.Start; i <= endPosition; i++) { + if (md[i] == '\n') { + newlineCount++; + } + } + return block.Line + newlineCount; +} diff --git a/tools/diagnostic-trivia.cs b/tools/diagnostic-trivia.cs new file mode 100644 index 0000000..418fcfe --- /dev/null +++ b/tools/diagnostic-trivia.cs @@ -0,0 +1,98 @@ +#:package Markdig.Signed@0.38.0 +using Markdig; +using Markdig.Renderers.Roundtrip; +using Markdig.Syntax; +// dotnet run ./diagnostic-trivia.cs ../tests/test-markdown.md +static int CountLines(string content) { + if (string.IsNullOrEmpty(content)) { + return 0; + } + + int count = 0; + using var reader = new StringReader(content.Replace("\r\n", "\n").Replace('\r', '\n')); + while (reader.ReadLine() is not null) { + count++; + } + + return count; +} + +string? firstArg = args.FirstOrDefault(a => !a.StartsWith("--", StringComparison.OrdinalIgnoreCase)); +bool analyzeAll = args.Any(a => string.Equals(a, "--all", StringComparison.OrdinalIgnoreCase)); +IEnumerable targets = []; + +if (analyzeAll) { + string testDir = Path.Combine("..", "tests"); + if (!Directory.Exists(testDir)) { + testDir = "tests"; + } + + if (!Directory.Exists(testDir)) { + Console.Error.WriteLine($"Error: Could not find tests directory at {testDir}"); + return; + } + + targets = Directory.GetFiles(testDir, "*.md", SearchOption.AllDirectories); +} +else if (!string.IsNullOrEmpty(firstArg)) { + targets = [firstArg]; +} +else { + string defaultPath = Path.Combine("..", "tests", "test-markdown.md"); + if (!File.Exists(defaultPath)) { + defaultPath = "tests/test-markdown.md"; + } + targets = [defaultPath]; +} + +int totalSourceLines = 0; +int totalRoundtripLines = 0; +int processedFiles = 0; + +foreach (string path in targets) { + if (!File.Exists(path)) { + Console.Error.WriteLine($"Error: Could not find markdown file at {path}"); + continue; + } + + string markdown = File.ReadAllText(path); + int sourceLines = CountLines(markdown); + + MarkdownDocument document = Markdown.Parse(markdown, trackTrivia: true); + using var writer = new StringWriter(); + var roundtrip = new RoundtripRenderer(writer); + roundtrip.Write(document); + int roundtripLines = CountLines(writer.ToString()); + + totalSourceLines += sourceLines; + totalRoundtripLines += roundtripLines; + processedFiles++; + + Console.WriteLine($"Analyzing: {Path.GetFullPath(path)}"); + Console.WriteLine($"Source line count: {sourceLines}"); + Console.WriteLine($"Roundtrip line count: {roundtripLines}"); + Console.WriteLine($"Delta: {roundtripLines - sourceLines}\n"); + + Console.WriteLine("=== Complete Trivia Analysis (LinesBefore, LinesAfter, TriviaBefore, TriviaAfter) ===\n"); + + for (int i = 0; i < document.Count; i++) { + Block block = document[i]; + Console.WriteLine($"[{i}] {block.GetType().Name,-20} Line {block.Line,3}"); + + if (block.LinesBefore != null && block.LinesBefore.Count > 0) { + Console.WriteLine($" LinesBefore.Count: {block.LinesBefore.Count}"); + } + + if (block.LinesAfter != null && block.LinesAfter.Count > 0) { + Console.WriteLine($" LinesAfter.Count: {block.LinesAfter.Count}"); + } + } +} + +if (processedFiles > 0) { + Console.WriteLine("=== Summary ==="); + Console.WriteLine($"Files analyzed: {processedFiles}"); + Console.WriteLine($"Total source lines: {totalSourceLines}"); + Console.WriteLine($"Total roundtrip lines: {totalRoundtripLines}"); + Console.WriteLine($"Total delta: {totalRoundtripLines - totalSourceLines}"); +} diff --git a/tools/test-line-calc.cs b/tools/test-line-calc.cs new file mode 100644 index 0000000..39eeaa2 --- /dev/null +++ b/tools/test-line-calc.cs @@ -0,0 +1,51 @@ +string path = args.Length > 0 ? args[0] : "../tests/test-markdown.md"; +string markdown = File.ReadAllText(path); + +// dotnet run ./test-line-calc.cs ../tests/test-markdown.md + +var pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseTaskLists() + .UsePipeTables() + .UseAutoLinks() + .EnableTrackTrivia() + .Build(); + +var document = Markdown.Parse(markdown, pipeline); + +Block? previousBlock = null; +int totalGap = 0; + +foreach (Block block in document) { + if (previousBlock is not null) { + int previousEndLine = GetBlockEndLine(previousBlock, markdown); + int gap = block.Line - previousEndLine - 1; + totalGap += gap; + Console.WriteLine($"{previousBlock.GetType().Name,-20} ends at {previousEndLine,3} -> {block.GetType().Name,-20} at {block.Line,3} = gap {gap}"); + } + previousBlock = block; +} + +Console.WriteLine($"\nTotal blank lines from gaps: {totalGap}"); +Console.WriteLine($"Document blocks: {document.Count}"); +Console.WriteLine($"Expected output lines: {document.Count + totalGap}"); + +int GetBlockEndLine(Block block, string md) { + // For container blocks, recursively find the last child's end line + if (block is ContainerBlock container && container.Count > 0) { + return GetBlockEndLine(container[^1], md); + } + // For fenced code blocks: opening fence + content lines + closing fence + if (block is FencedCodeBlock fenced && fenced.Lines.Count > 0) { + return block.Line + fenced.Lines.Count + 1; + } + // Count newlines within the block's span to find the ending line + int endPosition = Math.Min(block.Span.End, md.Length - 1); + int newlineCount = 0; + for (int i = block.Span.Start; i <= endPosition; i++) { + if (md[i] == '\n') { + newlineCount++; + } + } + return block.Line + newlineCount; +}