From 001f9722ae047e3b0f79fea5e1f6dcf4fce36d16 Mon Sep 17 00:00:00 2001 From: 2CHEVSKII Date: Sun, 20 Sep 2026 03:50:21 +0300 Subject: [PATCH] feat(render): add C# rendering prototype benchmarks --- .../FileSystem.prototype-fast.format.ps1xml | 32 +++ .../FileSystem.prototype.format.ps1xml | 40 ++++ .../prototypes/GlyDisplayNamePrototype.cs | 186 ++++++++++++++++++ .../prototypes/Measure-GlyPrototype.ps1 | 159 +++++++++++++++ benchmarks/prototypes/README.md | 11 ++ 5 files changed, 428 insertions(+) create mode 100644 benchmarks/prototypes/FileSystem.prototype-fast.format.ps1xml create mode 100644 benchmarks/prototypes/FileSystem.prototype.format.ps1xml create mode 100644 benchmarks/prototypes/GlyDisplayNamePrototype.cs create mode 100644 benchmarks/prototypes/Measure-GlyPrototype.ps1 create mode 100644 benchmarks/prototypes/README.md diff --git a/benchmarks/prototypes/FileSystem.prototype-fast.format.ps1xml b/benchmarks/prototypes/FileSystem.prototype-fast.format.ps1xml new file mode 100644 index 0000000..06a44a1 --- /dev/null +++ b/benchmarks/prototypes/FileSystem.prototype-fast.format.ps1xml @@ -0,0 +1,32 @@ + + + + + gly.FileSystem.Prototype.FastTable + + System.IO.FileInfo + System.IO.DirectoryInfo + + + + 7 + 25 + 10Right + + + + + + Mode + LastWriteTime + Length + + $global:GlyPrototypeRenderer.Render($_) + + + + + + + + diff --git a/benchmarks/prototypes/FileSystem.prototype.format.ps1xml b/benchmarks/prototypes/FileSystem.prototype.format.ps1xml new file mode 100644 index 0000000..8138687 --- /dev/null +++ b/benchmarks/prototypes/FileSystem.prototype.format.ps1xml @@ -0,0 +1,40 @@ + + + + + gly.FileSystem.Prototype.Table + + System.IO.FileInfo + System.IO.DirectoryInfo + + + + 7 + 25 + 10Right + + + + + + Mode + LastWriteTime + + + $module = Get-Module -Name gly + & $module { + param($inputObject) + Format-GlyFileSize -InputObject $inputObject + } $_ + + + + $global:GlyPrototypeRenderer.Render($_) + + + + + + + + diff --git a/benchmarks/prototypes/GlyDisplayNamePrototype.cs b/benchmarks/prototypes/GlyDisplayNamePrototype.cs new file mode 100644 index 0000000..df6cc7f --- /dev/null +++ b/benchmarks/prototypes/GlyDisplayNamePrototype.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +public sealed class GlyPrototypeRule +{ + private readonly Regex[] wildcardPatterns; + + public GlyPrototypeRule( + string token, + string kind, + string name, + string[] extensions, + string[] globs, + FileAttributes[] attributes) + { + Token = token; + Kind = string.IsNullOrEmpty(kind) ? null : kind; + Name = string.IsNullOrEmpty(name) ? null : name; + Extensions = extensions; + Attributes = attributes; + + var patterns = new List(); + var exactNames = new List(); + foreach (string glob in globs) + { + if (glob.Contains('*') || glob.Contains('?')) + { + string pattern = "^" + Regex.Escape(glob).Replace("\\*", ".*").Replace("\\?", ".") + "$"; + patterns.Add(new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled)); + } + else + { + exactNames.Add(glob); + } + } + + ExactNames = exactNames.ToArray(); + wildcardPatterns = patterns.ToArray(); + } + + public string Token { get; } + public string Kind { get; } + public string Name { get; } + public string[] Extensions { get; } + public string[] ExactNames { get; } + public FileAttributes[] Attributes { get; } + + public bool Matches(string kind, string name, FileAttributes attributes) + { + if (Kind != null && !string.Equals(Kind, kind, StringComparison.Ordinal)) + { + return false; + } + + if (Name != null && !string.Equals(Name, name, StringComparison.Ordinal)) + { + return false; + } + + if (Extensions.Length > 0) + { + bool matched = false; + foreach (string extension in Extensions) + { + if (name.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + matched = true; + break; + } + } + + if (!matched) + { + return false; + } + } + + if (ExactNames.Length > 0 || wildcardPatterns.Length > 0) + { + bool matched = false; + foreach (string exactName in ExactNames) + { + if (string.Equals(name, exactName, StringComparison.OrdinalIgnoreCase)) + { + matched = true; + break; + } + } + + if (!matched) + { + foreach (Regex pattern in wildcardPatterns) + { + if (pattern.IsMatch(name)) + { + matched = true; + break; + } + } + } + + if (!matched) + { + return false; + } + } + + foreach (FileAttributes required in Attributes) + { + if ((attributes & required) == 0) + { + return false; + } + } + + return true; + } +} + +public sealed class GlyDisplayNamePrototype +{ + private readonly GlyPrototypeRule[] rules; + private readonly IReadOnlyDictionary glyphs; + private readonly string fileStyle; + private readonly string directoryStyle; + private readonly string hiddenStyle; + private readonly string readOnlyStyle; + + public GlyDisplayNamePrototype( + GlyPrototypeRule[] rules, + IReadOnlyDictionary glyphs, + IReadOnlyDictionary palette) + { + this.rules = rules; + this.glyphs = glyphs; + fileStyle = ToAnsiStyle(palette["File"], false); + directoryStyle = ToAnsiStyle(palette["Directory"], true); + hiddenStyle = ToAnsiStyle(palette["Hidden"], false); + readOnlyStyle = ToAnsiStyle(palette["ReadOnly"], false); + } + + public string Render(FileSystemInfo item) + { + FileAttributes attributes = item.Attributes; + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new NotSupportedException("This prototype does not render symbolic links."); + } + + string name = item.Name; + string kind = item is DirectoryInfo ? "Directory" : "File"; + string token = "Default"; + for (int index = rules.Length - 1; index >= 0; index--) + { + if (rules[index].Matches(kind, name, attributes)) + { + token = rules[index].Token; + break; + } + } + + if (!glyphs.TryGetValue(token, out string glyph)) + { + glyph = glyphs["Default"]; + } + + string displayName = string.IsNullOrEmpty(glyph) ? name : glyph + " " + name; + string style = (attributes & FileAttributes.Hidden) != 0 ? hiddenStyle + : (attributes & FileAttributes.ReadOnly) != 0 ? readOnlyStyle + : kind == "Directory" ? directoryStyle + : fileStyle; + + return style + displayName + "\u001b[0m"; + } + + private static string ToAnsiStyle(string hexColor, bool bold) + { + int red = Convert.ToInt32(hexColor.Substring(1, 2), 16); + int green = Convert.ToInt32(hexColor.Substring(3, 2), 16); + int blue = Convert.ToInt32(hexColor.Substring(5, 2), 16); + string codes = bold ? "1;38;2" : "38;2"; + return "\u001b[" + codes + ";" + red + ";" + green + ";" + blue + "m"; + } +} diff --git a/benchmarks/prototypes/Measure-GlyPrototype.ps1 b/benchmarks/prototypes/Measure-GlyPrototype.ps1 new file mode 100644 index 0000000..418971f --- /dev/null +++ b/benchmarks/prototypes/Measure-GlyPrototype.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param( + [int] $ItemCount = 3000, + [int] $Iterations = 3 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Measure-Case { + param( + [string] $Name, + [scriptblock] $Action + ) + + & $Action | Out-Null + $samples = for ($iteration = 0; $iteration -lt $Iterations; $iteration++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + + $timer = [Diagnostics.Stopwatch]::StartNew() + & $Action | Out-Null + $timer.Stop() + $timer.Elapsed.TotalMilliseconds + } + + $ordered = @($samples | Sort-Object) + [pscustomobject]@{ + Scenario = $Name + MedianMs = [Math]::Round($ordered[[int] [Math]::Floor($ordered.Count / 2)], 2) + MinMs = [Math]::Round($ordered[0], 2) + MaxMs = [Math]::Round($ordered[-1], 2) + } +} + +$dataPath = Join-Path ([IO.Path]::GetTempPath()) ('gly-prototype-{0:N}' -f [guid]::NewGuid()) +if (Test-Path -LiteralPath $dataPath) { + throw "Temporary benchmark path already exists: $dataPath" +} + +New-Item -ItemType Directory -Path $dataPath | Out-Null +try { + $extensions = @('.ps1', '.psm1', '.psd1', '.cs', '.js', '.ts', '.d.ts', '.json', '.md', '.tar.gz', '.txt', '.unknown') + for ($index = 0; $index -lt $ItemCount; $index++) { + if (($index % 50) -eq 0) { + New-Item -ItemType Directory -Path (Join-Path $dataPath ('dir-{0:D5}' -f $index)) | Out-Null + continue + } + + $name = 'file-{0:D5}{1}' -f $index, $extensions[$index % $extensions.Count] + $path = Join-Path $dataPath $name + [IO.File]::WriteAllText($path, "gly benchmark item $index") + if (($index % 40) -eq 0) { + $item = Get-Item -LiteralPath $path + $item.Attributes = $item.Attributes -bor [IO.FileAttributes]::Hidden + } + } + + [IO.File]::WriteAllText((Join-Path $dataPath '.gitignore'), "bin/`nobj/`n") + [IO.File]::WriteAllText((Join-Path $dataPath 'Dockerfile'), "FROM scratch`n") + + Import-Module (Join-Path $PSScriptRoot '../../src/gly.psd1') -Force + Set-GlyConfiguration -StyleRenderer Ansi -ShowGlyphs $true -ShowColors $true -RespectNoColor $false | Out-Null + $items = @(Get-ChildItem -LiteralPath $dataPath -Force) + $module = Get-Module gly + + $baselineTable = $items | Format-Table -View gly.FileSystem.Table | Out-String -Width 4096 + $setupTimer = [Diagnostics.Stopwatch]::StartNew() + $state = & $module { + [pscustomobject]@{ + Catalog = @(Get-GlyBuiltInSelectorCatalog) + Glyphs = $script:GlyGlyphSets['NerdFonts'].Map + Palette = $script:GlyThemes['DefaultDark'].Palette + } + } + + $timer = [Diagnostics.Stopwatch]::StartNew() + Add-Type -Path (Join-Path $PSScriptRoot 'GlyDisplayNamePrototype.cs') + $timer.Stop() + $compileMs = [Math]::Round($timer.Elapsed.TotalMilliseconds, 2) + + $rules = [GlyPrototypeRule[]] @( + foreach ($definition in $state.Catalog) { + $selector = $definition.Selector + $kind = if ($null -eq $selector.Kind) { $null } else { $selector.Kind.ToString() } + [GlyPrototypeRule]::new( + $definition.Token, + $kind, + $selector.Name, + [string[]] $selector.NormalizedExtension, + [string[]] $selector.Glob, + [IO.FileAttributes[]] $selector.Attributes + ) + } + ) + + $glyphs = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) + foreach ($key in $state.Glyphs.Keys) { + $glyphs.Add([string] $key, [string] $state.Glyphs[$key]) + } + + $palette = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) + foreach ($key in $state.Palette.Keys) { + $palette.Add([string] $key, [string] $state.Palette[$key]) + } + + $global:GlyPrototypeRenderer = [GlyDisplayNamePrototype]::new($rules, $glyphs, $palette) + $setupTimer.Stop() + $setupMs = [Math]::Round($setupTimer.Elapsed.TotalMilliseconds, 2) + + $mismatches = 0 + foreach ($item in $items) { + $powerShellName = Get-GlyFileSystemDisplayName -InputObject $item + $prototypeName = $global:GlyPrototypeRenderer.Render($item) + if ($powerShellName -cne $prototypeName) { + $mismatches++ + if ($mismatches -le 3) { + Write-Warning "Mismatch for $($item.Name): PowerShell '$powerShellName'; C# '$prototypeName'" + } + } + } + if ($mismatches -gt 0) { + throw "C# prototype produced $mismatches different display names." + } + + Update-FormatData -PrependPath (Join-Path $PSScriptRoot 'FileSystem.prototype.format.ps1xml') + $prototypeTable = $items | Format-Table -View gly.FileSystem.Prototype.Table | Out-String -Width 4096 + if ($baselineTable -cne $prototypeTable) { + throw 'C# prototype table differs from the PowerShell table.' + } + + Update-FormatData -PrependPath (Join-Path $PSScriptRoot 'FileSystem.prototype-fast.format.ps1xml') + $fastTable = $items | Format-Table -View gly.FileSystem.Prototype.FastTable | Out-String -Width 4096 + if ($baselineTable -cne $fastTable) { + throw 'C# prototype table with native Length differs from the PowerShell table.' + } + + $results = @( + Measure-Case 'Read Mode property' { foreach ($item in $items) { [void] $item.Mode } } + Measure-Case 'Direct Length property' { foreach ($item in $items) { if ($item -is [IO.FileInfo]) { [void] $item.Length } } } + Measure-Case 'Size via module bridge' { foreach ($item in $items) { $currentModule = Get-Module gly; [void] (& $currentModule { param($inputObject) Format-GlyFileSize $inputObject } $item) } } + Measure-Case 'PowerShell display name' { foreach ($item in $items) { [void] (Get-GlyFileSystemDisplayName $item) } } + Measure-Case 'C# display name' { foreach ($item in $items) { [void] $global:GlyPrototypeRenderer.Render($item) } } + Measure-Case 'Native explicit table' { $items | Format-Table Mode, LastWriteTime, Length, Name | Out-String -Width 4096 | Out-Null } + Measure-Case 'PowerShell gly table' { $items | Format-Table -View gly.FileSystem.Table | Out-String -Width 4096 | Out-Null } + Measure-Case 'C# prototype table' { $items | Format-Table -View gly.FileSystem.Prototype.Table | Out-String -Width 4096 | Out-Null } + Measure-Case 'C# with native Length' { $items | Format-Table -View gly.FileSystem.Prototype.FastTable | Out-String -Width 4096 | Out-Null } + ) + + "Items: $($items.Count); prototype setup: $setupMs ms (C# compile: $compileMs ms); output mismatches: $mismatches" + $results | Format-Table -AutoSize +} +finally { + Remove-Variable GlyPrototypeRenderer -Scope Global -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $dataPath) { + Remove-Item -LiteralPath $dataPath -Recurse -Force + } +} diff --git a/benchmarks/prototypes/README.md b/benchmarks/prototypes/README.md new file mode 100644 index 0000000..c989e82 --- /dev/null +++ b/benchmarks/prototypes/README.md @@ -0,0 +1,11 @@ +# C# display-name prototype + +Run the isolated comparison with PowerShell 7+: + +```powershell +pwsh -NoProfile -File ./benchmarks/prototypes/Measure-GlyPrototype.ps1 -ItemCount 3000 -Iterations 5 +``` + +The script creates disposable file-system data, compiles `GlyDisplayNamePrototype.cs` in memory, and checks every display name and the complete table output against the current module before timing either implementation. It measures the cost of the existing size-formatting bridge separately. The second prototype table uses the native `Length` property and is equivalent only with the default raw-size setting. + +This is an experiment, not a module integration. It handles the built-in NerdFonts glyph set, the DefaultDark theme, ANSI output, and ordinary files and directories. Symbolic links, user-registered rules, other themes and renderers, runtime configuration changes, and binary-size formatting still need implementation and verification. Runtime C# compilation is included in the reported setup time; a packaged assembly would avoid that compilation step.