diff --git a/src/Highlighter.php b/src/Highlighter.php index ebce86ca..b33d8dbf 100644 --- a/src/Highlighter.php +++ b/src/Highlighter.php @@ -10,6 +10,7 @@ use Tempest\Highlight\Languages\Bash\BashLanguage; use Tempest\Highlight\Languages\BBCode\BBCodeLanguage; use Tempest\Highlight\Languages\Blade\BladeLanguage; +use Tempest\Highlight\Languages\CSharp\CSharpLanguage; use Tempest\Highlight\Languages\C\CLanguage; use Tempest\Highlight\Languages\Cpp\CppLanguage; use Tempest\Highlight\Languages\Css\CssLanguage; @@ -78,6 +79,7 @@ public function __construct( ->addLanguage(new BashLanguage()) ->addLanguage(new BBCodeLanguage()) ->addLanguage(new BladeLanguage()) + ->addLanguage(new CSharpLanguage()) ->addLanguage(new CLanguage()) ->addLanguage(new CppLanguage()) ->addLanguage(new CssLanguage()) diff --git a/src/Languages/CSharp/CSharpLanguage.php b/src/Languages/CSharp/CSharpLanguage.php new file mode 100644 index 00000000..c0b0fecc --- /dev/null +++ b/src/Languages/CSharp/CSharpLanguage.php @@ -0,0 +1,58 @@ +[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:Attribute)?)'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::ATTRIBUTE; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpCallablePattern.php b/src/Languages/CSharp/Patterns/CSharpCallablePattern.php new file mode 100644 index 00000000..ddde4026 --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpCallablePattern.php @@ -0,0 +1,27 @@ +()', output: 'Method')] +final readonly class CSharpCallablePattern implements Pattern +{ + use IsPattern; + + public function getPattern(): string + { + return '(?@?[A-Za-z_]\w*)(?=\h*(?:<[^<>]*>\h*)?\()'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::PROPERTY; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpDeclarationPattern.php b/src/Languages/CSharp/Patterns/CSharpDeclarationPattern.php new file mode 100644 index 00000000..58e557de --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpDeclarationPattern.php @@ -0,0 +1,27 @@ +@?[A-Za-z_]\w*)'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::TYPE; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpDelegatePattern.php b/src/Languages/CSharp/Patterns/CSharpDelegatePattern.php new file mode 100644 index 00000000..0ad0faf5 --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpDelegatePattern.php @@ -0,0 +1,26 @@ +]*>)?(?:\h*\[\h*])?\??)\h+(?@?[A-Za-z_]\w*)(?=\h*(?:<[^<>]*>\h*)?\()'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::TYPE; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpKeywordPattern.php b/src/Languages/CSharp/Patterns/CSharpKeywordPattern.php new file mode 100644 index 00000000..8593c50f --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpKeywordPattern.php @@ -0,0 +1,27 @@ +abstract|add|alias|and|as|ascending|async|await|base|break|by|case|catch|checked|class|const|continue|default|delegate|descending|do|else|enum|equals|event|explicit|extern|file|finally|fixed|for|foreach|from|get|global|goto|group|if|implicit|in|init|interface|internal|into|is|join|let|lock|managed|nameof|namespace|new|not|notnull|on|operator|or|orderby|out|override|params|partial|private|protected|public|readonly|record|ref|remove|required|return|scoped|sealed|select|set|sizeof|stackalloc|static|struct|switch|this|throw|try|typeof|unchecked|unmanaged|unsafe|using|value|virtual|volatile|when|where|while|with|yield)\b'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::KEYWORD; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpLiteralPattern.php b/src/Languages/CSharp/Patterns/CSharpLiteralPattern.php new file mode 100644 index 00000000..52c24183 --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpLiteralPattern.php @@ -0,0 +1,27 @@ +true|false|null)\b'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::LITERAL; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpNamespacePattern.php b/src/Languages/CSharp/Patterns/CSharpNamespacePattern.php new file mode 100644 index 00000000..b79cf616 --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpNamespacePattern.php @@ -0,0 +1,27 @@ +(?:global::)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::TYPE; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpNumberPattern.php b/src/Languages/CSharp/Patterns/CSharpNumberPattern.php new file mode 100644 index 00000000..42c29335 --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpNumberPattern.php @@ -0,0 +1,27 @@ +(?:0[xX][\dA-Fa-f](?:_?[\dA-Fa-f])*|0[bB][01](?:_?[01])*|(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?|\.\d(?:_?\d)*)(?:[eE][+-]?\d(?:_?\d)*)?)(?:[uUlLfFdDmM]{1,2})?)(?![\w.])/'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::NUMBER; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpOperatorPattern.php b/src/Languages/CSharp/Patterns/CSharpOperatorPattern.php new file mode 100644 index 00000000..012191af --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpOperatorPattern.php @@ -0,0 +1,27 @@ + x + 1', output: '=>')] +#[PatternTest(input: 'user?.Name', output: '?.')] +final readonly class CSharpOperatorPattern implements Pattern +{ + use IsPattern; + + public function getPattern(): string + { + return '/(?=>|\?\?=|\?\?|\?\.|\?\[|\.\.|<<=?|>>=?|\+\+|--|&&|\|\||==|!=|<=|>=|[-+*\/%&|^~!<>=]=?|[?:;,.{}()\[\]])/'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::OPERATOR; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpPreprocessorPattern.php b/src/Languages/CSharp/Patterns/CSharpPreprocessorPattern.php new file mode 100644 index 00000000..732ef68e --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpPreprocessorPattern.php @@ -0,0 +1,27 @@ +#\h*(?:define|undef|if|elif|else|endif|line|error|warning|region|endregion|pragma|nullable|r|load)\b)/'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::KEYWORD; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpPropertyPattern.php b/src/Languages/CSharp/Patterns/CSharpPropertyPattern.php new file mode 100644 index 00000000..bc1e0b5b --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpPropertyPattern.php @@ -0,0 +1,27 @@ +)))(?@?[A-Za-z_]\w*)(?!\h*\()/'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::PROPERTY; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpStringPattern.php b/src/Languages/CSharp/Patterns/CSharpStringPattern.php new file mode 100644 index 00000000..dd8c7eca --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpStringPattern.php @@ -0,0 +1,28 @@ +\$*+(?"{3,})(?:(?!\k)[\s\S])*+\k|(?:\$@|@\$|@)"(?:[^"]++|"")*+"|\$?"(?:\\\\.|[^"\\\\\r\n]++)*+"|\'(?:\\\\.|[^\'\\\\\r\n])\')/'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::VALUE; + } +} diff --git a/src/Languages/CSharp/Patterns/CSharpTypePattern.php b/src/Languages/CSharp/Patterns/CSharpTypePattern.php new file mode 100644 index 00000000..c274a5fd --- /dev/null +++ b/src/Languages/CSharp/Patterns/CSharpTypePattern.php @@ -0,0 +1,27 @@ +bool|byte|sbyte|char|decimal|double|float|int|uint|nint|nuint|long|ulong|short|ushort|object|string|void|dynamic|var|(?Formats immutable events for the audit stream. +[AttributeUsage(AttributeTargets.Class)] +public sealed class AuditedAttribute : Attribute {} + +public readonly record struct Event(Guid Id, string Name, T Payload); + +public interface IRepository where T : notnull +{ + IAsyncEnumerable StreamAsync(CancellationToken cancellationToken = default); +} + +[Audited] +public sealed class EventFormatter +{ + private const decimal Rate = 12.5m; + private const string Root = @"C:\logs"; + + public required string Prefix { get; init; } + + public async Task FormatAsync(Event? item) where T : notnull + { + ArgumentNullException.ThrowIfNull(item); + + var names = from value in new[] { item.Name } + where !string.IsNullOrWhiteSpace(value) + select value.Trim(); + + await Task.Yield(); + + return $"""{Prefix}: {string.Join(", ", names)} ({item.Payload})"""; + } +} diff --git a/tests/Bench/HighlighterBench.php b/tests/Bench/HighlighterBench.php index 675023e7..857ddfd0 100644 --- a/tests/Bench/HighlighterBench.php +++ b/tests/Bench/HighlighterBench.php @@ -21,6 +21,7 @@ final class HighlighterBench 'bash' => 'bash.txt', 'bbcode' => 'bbcode.txt', 'blade' => 'blade.txt', + 'csharp' => 'csharp.txt', 'c' => 'c.txt', 'cpp' => 'cpp.txt', 'css' => 'css.txt', diff --git a/tests/Languages/CSharp/CSharpLanguageTest.php b/tests/Languages/CSharp/CSharpLanguageTest.php new file mode 100644 index 00000000..b47c0934 --- /dev/null +++ b/tests/Languages/CSharp/CSharpLanguageTest.php @@ -0,0 +1,134 @@ +assertContains($name, $highlighter->getSupportedLanguageNames()); + + $highlighter->parse('', $name); + + $this->assertInstanceOf(CSharpLanguage::class, $highlighter->getCurrentLanguage()); + } + } + + #[Test] + #[DataProvider('provide_it_highlights_representative_syntax_cases')] + public function it_highlights_representative_syntax(string $source, string $expected): void + { + $highlighted = (new Highlighter())->parse($source, 'csharp'); + + $this->assertStringContainsString($expected, $highlighted); + } + + public static function provide_it_highlights_representative_syntax_cases(): iterable + { + yield 'preprocessor directive' => ['#nullable enable', '#nullable']; + yield 'namespace keyword' => ['namespace Demo.Tools;', 'namespace']; + yield 'namespace name' => ['namespace Demo.Tools;', 'Demo.Tools']; + yield 'using directive' => ['using System.Text;', 'using']; + yield 'attribute' => ['[Obsolete]', 'Obsolete']; + yield 'line comment' => ['// note', '// note']; + yield 'XML documentation' => ['/// Note', '/// <summary>Note</summary>']; + yield 'block comment' => ['/* note */', '/* note */']; + yield 'regular string' => ['"hello\\n"', '"hello\\n"']; + yield 'verbatim string' => ['@"c:\\temp"', '@"c:\\temp"']; + yield 'interpolated string' => ['$"Hello {name}"', '$"Hello {name}"']; + yield 'raw string' => ['""""""', '"""<tag>"""']; + yield 'character' => ["'x'", '\'x\'']; + yield 'number' => ['0xCAFE_F00DUL', '0xCAFE_F00DUL']; + yield 'keyword' => ['return', 'return']; + yield 'modifier' => ['public', 'public']; + yield 'built-in type' => ['string', 'string']; + yield 'class' => ['class Widget {}', 'Widget']; + yield 'interface' => ['interface IService {}', 'IService']; + yield 'struct' => ['struct Point {}', 'Point']; + yield 'record' => ['record User(int Id);', 'User']; + yield 'enum' => ['enum Status {}', 'Status']; + yield 'delegate' => ['delegate void Handler();', 'Handler']; + yield 'generic type' => ['List values;', 'List']; + yield 'nullable type' => ['string? value;', 'string?']; + yield 'literal' => ['return null;', 'null']; + yield 'method call' => ['Render();', 'Render']; + yield 'property' => ['public string Name { get; init; }', 'Name']; + yield 'lambda' => ['value => value + 1', '=>']; + yield 'query expression' => ['from item in items select item', 'from']; + yield 'operator' => ['left ?? right', '??']; + yield 'punctuation' => ['return value;', ';']; + } + + #[Test] + public function it_escapes_html_and_strips_reserved_markers(): void + { + $source = "class Markup {\r\n" + . " string Value = \"<©> ❷img src=x onerror=alert(1)❸ ❶❹❿\";\r" + . " string Script = \"\";\r\n" + . "}\r\n"; + + $highlighted = (new Highlighter())->parse($source, 'csharp'); + $renderedSource = html_entity_decode(strip_tags($highlighted), ENT_QUOTES | ENT_HTML5); + + $expectedSource = str_replace(["\r\n", "\r"], "\n", str_replace(['❶', '❷', '❸', '❹', '❿'], '', $source)); + + $this->assertSame($expectedSource, $renderedSource); + $this->assertStringNotContainsString('