From 3cdd2603f5b1d56e0f3197e8e2f99047bd6d57d5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:18:25 -0400 Subject: [PATCH 01/29] Add lineeditor command router --- lineeditor/Icod.LineEditor.Router.csproj | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 lineeditor/Icod.LineEditor.Router.csproj diff --git a/lineeditor/Icod.LineEditor.Router.csproj b/lineeditor/Icod.LineEditor.Router.csproj new file mode 100644 index 0000000..d9811d2 --- /dev/null +++ b/lineeditor/Icod.LineEditor.Router.csproj @@ -0,0 +1,53 @@ + + + + Exe + net10.0 + 13.0 + enable + enable + true + lineeditor + Icod.LineEditor.Router + Debug;Release;Staging + true + lineeditor + Icod.LineEditor.Tools + Timothy J. Bruce + Managed command router for the Icod.LineEditor ed, red, and sed tools. + README.md + GPL-3.0-or-later + + + + + + + + + + + + 2 + true + full + false + DEBUG;TRACE + false + + + 3 + true + full + false + TRACE + false + + + 4 + portable + true + true + CS1591 + + From 459f73df216b045d93f33b70f668bad3bcce4968 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:18:39 -0400 Subject: [PATCH 02/29] Add lineeditor router entry point --- lineeditor/Program.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 lineeditor/Program.cs diff --git a/lineeditor/Program.cs b/lineeditor/Program.cs new file mode 100644 index 0000000..fd46c9f --- /dev/null +++ b/lineeditor/Program.cs @@ -0,0 +1,27 @@ +namespace Icod.LineEditor.Router; + +/// Provides the process entry point for the lineeditor command router. +public static class Program { + /// Runs the router with cooperative Ctrl+C cancellation. + public static async Task Main( string[] args ) { + ArgumentNullException.ThrowIfNull( args ); + + using var cancellation = new CancellationTokenSource(); + ConsoleCancelEventHandler handler = ( _, eventArgs ) => { + eventArgs.Cancel = true; + cancellation.Cancel(); + }; + Console.CancelKeyPress += handler; + try { + return await Command.RunAsync( + args, + Console.In, + Console.Out, + Console.Error, + cancellation.Token + ).ConfigureAwait( false ); + } finally { + Console.CancelKeyPress -= handler; + } + } +} From 1e65809e0ba7cd51e3e60b0cc51d68fef5e7b12c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:18:57 -0400 Subject: [PATCH 03/29] Add lineeditor router dispatch --- lineeditor/src/Command.cs | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 lineeditor/src/Command.cs diff --git a/lineeditor/src/Command.cs b/lineeditor/src/Command.cs new file mode 100644 index 0000000..0b58522 --- /dev/null +++ b/lineeditor/src/Command.cs @@ -0,0 +1,109 @@ +namespace Icod.LineEditor.Router; + +using System.Reflection; +using EdCommand = Icod.LineEditor.Ed.Command; +using RedCommand = Icod.LineEditor.Red.Command; +using SedCommand = Icod.LineEditor.Sed.Command; + +/// Routes lineeditor COMMAND [args...] to managed line-editor commands. +public static class Command { + private const string CommandName = "lineeditor"; + private const int UsageError = 2; + private const int Canceled = 130; + + /// Runs the router with caller-owned text streams. + public static async Task RunAsync( + string[] args, + TextReader stdin, + TextWriter stdout, + TextWriter stderr, + CancellationToken cancellationToken = default + ) { + ArgumentNullException.ThrowIfNull( args ); + ArgumentNullException.ThrowIfNull( stdin ); + ArgumentNullException.ThrowIfNull( stdout ); + ArgumentNullException.ThrowIfNull( stderr ); + + if ( cancellationToken.IsCancellationRequested ) { + return Canceled; + } + if ( 0 == args.Length ) { + await stderr.WriteLineAsync( + $"{CommandName}: missing command; use --help to list supported commands" + ).ConfigureAwait( false ); + return UsageError; + } + + var commandName = args[ 0 ]; + if ( commandName is "--help" or "-h" ) { + await stdout.WriteAsync( GetHelpText() ).ConfigureAwait( false ); + return 0; + } + if ( commandName is "--version" or "-V" ) { + await stdout.WriteLineAsync( + $"{CommandName} (Icod.LineEditor) {GetSemanticVersion()}" + ).ConfigureAwait( false ); + return 0; + } + if ( commandName is not ( "ed" or "red" or "sed" ) ) { + await stderr.WriteLineAsync( + $"{CommandName}: unknown command '{commandName}'; use --help to list supported commands" + ).ConfigureAwait( false ); + return UsageError; + } + + var commandArguments = args[ 1.. ]; + try { + return commandName switch { + "ed" => await EdCommand.RunAsync( + commandArguments, + stdin, + stdout, + stderr, + cancellationToken + ).ConfigureAwait( false ), + "red" => await RedCommand.RunAsync( + commandArguments, + stdin, + stdout, + stderr, + cancellationToken + ).ConfigureAwait( false ), + "sed" => await SedCommand.RunAsync( + commandArguments, + stdin, + stdout, + stderr, + cancellationToken + ).ConfigureAwait( false ), + _ => throw new InvalidOperationException( "Known command dispatch was incomplete." ) + }; + } catch ( OperationCanceledException ) { + return Canceled; + } + } + + private static string GetHelpText() => + $"Usage: {CommandName} COMMAND [OPTION]... [ARG]...{Environment.NewLine}" + + Environment.NewLine + + $"Commands:{Environment.NewLine}" + + $" ed line-oriented text editor{Environment.NewLine}" + + $" red restricted line-oriented text editor{Environment.NewLine}" + + $" sed stream editor{Environment.NewLine}" + + Environment.NewLine + + $"Router options:{Environment.NewLine}" + + $" -h, --help display this help and exit{Environment.NewLine}" + + $" -V, --version output the router version and exit{Environment.NewLine}" + + Environment.NewLine + + $"Run '{CommandName} COMMAND --help' for command-specific help.{Environment.NewLine}"; + + private static string GetSemanticVersion() { + var version = typeof( Command ) + .Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? "0.0.0"; + var separator = version.IndexOf( '+', StringComparison.Ordinal ); + return 0 <= separator ? version[ ..separator ] : version; + } +} From 0950a521c512fba38936773cce522ad9b64f29fb Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:19:08 -0400 Subject: [PATCH 04/29] Document lineeditor router --- lineeditor/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 lineeditor/README.md diff --git a/lineeditor/README.md b/lineeditor/README.md new file mode 100644 index 0000000..e20d0a2 --- /dev/null +++ b/lineeditor/README.md @@ -0,0 +1,11 @@ +# Icod.LineEditor.Tools + +`lineeditor` is the distribution router for the managed Icod.LineEditor command suite. + +```text +lineeditor ed [OPTION]... +lineeditor red [OPTION]... +lineeditor sed [OPTION]... +``` + +The router dispatches directly to the managed command implementations and does not spawn the standalone executables. The standalone `ed`, `red`, and `sed` commands remain first-class build and release outputs. From e2ff6a02b7e9f81eb459d666db7f25391b7ae4a3 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:19:20 -0400 Subject: [PATCH 05/29] Add lineeditor router tests --- .../Icod.LineEditor.Router.Tests.csproj | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/Router.Tests/Icod.LineEditor.Router.Tests.csproj diff --git a/tests/Router.Tests/Icod.LineEditor.Router.Tests.csproj b/tests/Router.Tests/Icod.LineEditor.Router.Tests.csproj new file mode 100644 index 0000000..546ffba --- /dev/null +++ b/tests/Router.Tests/Icod.LineEditor.Router.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + 13.0 + enable + enable + false + true + Icod.LineEditor.Router.Tests + Icod.LineEditor.Router.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + From 0103e427d50f0af0c4c794b3bb6774d69abd784b Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:19:35 -0400 Subject: [PATCH 06/29] Cover lineeditor router dispatch --- tests/Router.Tests/src/CommandTests.cs | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/Router.Tests/src/CommandTests.cs diff --git a/tests/Router.Tests/src/CommandTests.cs b/tests/Router.Tests/src/CommandTests.cs new file mode 100644 index 0000000..81efd90 --- /dev/null +++ b/tests/Router.Tests/src/CommandTests.cs @@ -0,0 +1,52 @@ +namespace Icod.LineEditor.Router.Tests; + +using Xunit; + +public sealed class CommandTests { + [Fact] + public async Task HelpListsAllCommands() { + var output = new StringWriter(); + var status = await Command.RunAsync( + [ "--help" ], + new StringReader( string.Empty ), + output, + new StringWriter() + ); + + Assert.Equal( 0, status ); + Assert.Contains( " ed ", output.ToString() ); + Assert.Contains( " red ", output.ToString() ); + Assert.Contains( " sed ", output.ToString() ); + } + + [Fact] + public async Task UnknownCommandIsUsageError() { + var error = new StringWriter(); + var status = await Command.RunAsync( + [ "nope" ], + new StringReader( string.Empty ), + new StringWriter(), + error + ); + + Assert.Equal( 2, status ); + Assert.Contains( "unknown command", error.ToString() ); + } + + [Theory] + [InlineData( "ed" )] + [InlineData( "red" )] + [InlineData( "sed" )] + public async Task DispatchesVersionToManagedCommand( string commandName ) { + var output = new StringWriter(); + var status = await Command.RunAsync( + [ commandName, "--version" ], + new StringReader( string.Empty ), + output, + new StringWriter() + ); + + Assert.Equal( 0, status ); + Assert.Contains( "1.0", output.ToString() ); + } +} From 27145d7324ce1b2fe5431be01ceebc8930cee589 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:20:23 -0400 Subject: [PATCH 07/29] Add lineeditor router to solution --- Icod.LineEditor.sln | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Icod.LineEditor.sln b/Icod.LineEditor.sln index aedb4e0..bc5fdb8 100644 --- a/Icod.LineEditor.sln +++ b/Icod.LineEditor.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Red", "red\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Sed", "sed\Icod.LineEditor.Sed.csproj", "{58B05E59-4BFE-41B1-9E0C-F161547F7362}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Router", "lineeditor\Icod.LineEditor.Router.csproj", "{6FD0A77C-ECA4-47F4-8B17-87C41BE61301}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9E7C04E6-2E76-4D11-89BD-14C238881678}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Ed.Shared.Tests", "tests\Ed.Shared.Tests\Icod.LineEditor.Ed.Shared.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0101}" @@ -20,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Red.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Sed.Tests", "tests\Sed.Tests\Icod.LineEditor.Sed.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0006}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Router.Tests", "tests\Router.Tests\Icod.LineEditor.Router.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0104}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +55,12 @@ Global {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Staging|Any CPU.Build.0 = Staging|Any CPU {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Release|Any CPU.ActiveCfg = Release|Any CPU {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Release|Any CPU.Build.0 = Release|Any CPU + {6FD0A77C-ECA4-47F4-8B17-87C41BE61301}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6FD0A77C-ECA4-47F4-8B17-87C41BE61301}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6FD0A77C-ECA4-47F4-8B17-87C41BE61301}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {6FD0A77C-ECA4-47F4-8B17-87C41BE61301}.Staging|Any CPU.Build.0 = Staging|Any CPU + {6FD0A77C-ECA4-47F4-8B17-87C41BE61301}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6FD0A77C-ECA4-47F4-8B17-87C41BE61301}.Release|Any CPU.Build.0 = Release|Any CPU {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Debug|Any CPU.Build.0 = Debug|Any CPU {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Staging|Any CPU.ActiveCfg = Staging|Any CPU @@ -75,6 +85,12 @@ Global {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Staging|Any CPU.Build.0 = Staging|Any CPU {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Release|Any CPU.ActiveCfg = Release|Any CPU {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Release|Any CPU.Build.0 = Release|Any CPU + {F05B4136-23C6-4CF5-8A6A-920F20DC0104}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F05B4136-23C6-4CF5-8A6A-920F20DC0104}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F05B4136-23C6-4CF5-8A6A-920F20DC0104}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {F05B4136-23C6-4CF5-8A6A-920F20DC0104}.Staging|Any CPU.Build.0 = Staging|Any CPU + {F05B4136-23C6-4CF5-8A6A-920F20DC0104}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F05B4136-23C6-4CF5-8A6A-920F20DC0104}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -84,5 +100,6 @@ Global {F05B4136-23C6-4CF5-8A6A-920F20DC0102} = {9E7C04E6-2E76-4D11-89BD-14C238881678} {F05B4136-23C6-4CF5-8A6A-920F20DC0103} = {9E7C04E6-2E76-4D11-89BD-14C238881678} {F05B4136-23C6-4CF5-8A6A-920F20DC0006} = {9E7C04E6-2E76-4D11-89BD-14C238881678} + {F05B4136-23C6-4CF5-8A6A-920F20DC0104} = {9E7C04E6-2E76-4D11-89BD-14C238881678} EndGlobalSection -EndGlobal \ No newline at end of file +EndGlobal From db7cd724170ec88eb6d174acc5477eb901c3ae2f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:21:48 -0400 Subject: [PATCH 08/29] Normalize local build and packaging tools --- build.cmd | 69 ++------------------------ build.sh | 59 +---------------------- packaging/BuildReleaseArchive.ps1 | 24 ++++++++++ packaging/Get-RepositoryMetadata.ps1 | 21 ++++++++ packaging/Invoke-Build.ps1 | 14 ++++++ packaging/README.md | 7 +++ packaging/RepositoryTools.psm1 | 72 ++++++++++++++++++++++++++++ packaging/VerifyDistribution.ps1 | 14 ++++++ packaging/VerifyPackageArtifact.ps1 | 19 ++++++++ 9 files changed, 176 insertions(+), 123 deletions(-) mode change 100644 => 100755 build.sh create mode 100644 packaging/BuildReleaseArchive.ps1 create mode 100644 packaging/Get-RepositoryMetadata.ps1 create mode 100644 packaging/Invoke-Build.ps1 create mode 100644 packaging/README.md create mode 100644 packaging/RepositoryTools.psm1 create mode 100644 packaging/VerifyDistribution.ps1 create mode 100644 packaging/VerifyPackageArtifact.ps1 diff --git a/build.cmd b/build.cmd index 87df446..2038608 100644 --- a/build.cmd +++ b/build.cmd @@ -1,69 +1,6 @@ @echo off setlocal - -if "%~1"=="" goto all - -if /I "%~1"=="clean" goto run-clean -if /I "%~1"=="restore" goto run-restore -if /I "%~1"=="build" goto run-build -if /I "%~1"=="test" goto run-test - -echo Invalid section: "%~1" -echo Usage: %~nx0 [clean^|restore^|build^|test] -exit /b 1 - - -:all -call :clean || exit /b 1 -call :restore || exit /b 1 -call :build || exit /b 1 -call :test || exit /b 1 -exit /b 0 - - -:run-clean -call :clean -exit /b %errorlevel% - - -:run-restore -call :restore -exit /b %errorlevel% - - -:run-build -call :build -exit /b %errorlevel% - - -:run-test -call :test -exit /b %errorlevel% - - -:clean -echo. -echo === Clean === -dotnet clean Icod.LineEditor.sln -c Debug -exit /b %errorlevel% - - -:restore -echo. -echo === Restore === -dotnet restore Icod.LineEditor.sln -exit /b %errorlevel% - - -:build -echo. -echo === Build === -dotnet build Icod.LineEditor.sln -c Debug --no-restore -exit /b %errorlevel% - - -:test -echo. -echo === Test === -dotnet test Icod.LineEditor.sln -c Debug --no-build +set "SECTION=%~1" +if "%SECTION%"=="" set "SECTION=all" +powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File packaging\Invoke-Build.ps1 -Section "%SECTION%" -Configuration Debug exit /b %errorlevel% diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 2b634e8..38264a2 --- a/build.sh +++ b/build.sh @@ -1,59 +1,4 @@ #!/usr/bin/env sh set -eu - -clean() -{ - printf '\n=== Clean ===\n' - dotnet clean Icod.LineEditor.sln -c Debug -} - -restore() -{ - printf '\n=== Restore ===\n' - dotnet restore Icod.LineEditor.sln -} - -build() -{ - printf '\n=== Build ===\n' - dotnet build Icod.LineEditor.sln -c Debug --no-restore -} - -test() -{ - printf '\n=== Test ===\n' - dotnet test Icod.LineEditor.sln \ - -c Debug \ - --no-build -} - -case "${1-}" in - "") - clean - restore - build - test - ;; - - clean) - clean - ;; - - restore) - restore - ;; - - build) - build - ;; - - test) - test - ;; - - *) - printf 'Invalid section: %s\n' "$1" >&2 - printf 'Usage: %s [clean|restore|build|test]\n' "$0" >&2 - exit 1 - ;; -esac +section=${1-all} +pwsh -NoLogo -NoProfile -File ./packaging/Invoke-Build.ps1 -Section "$section" -Configuration Debug diff --git a/packaging/BuildReleaseArchive.ps1 b/packaging/BuildReleaseArchive.ps1 new file mode 100644 index 0000000..bfc0bd0 --- /dev/null +++ b/packaging/BuildReleaseArchive.ps1 @@ -0,0 +1,24 @@ +param([Parameter(Mandatory=$true)][string]$RuntimeIdentifier,[Parameter(Mandatory=$true)][string]$Version,[ValidateSet('Debug','Staging','Release')][string]$Configuration='Release',[string]$ArchiveBaseName='Icod.LineEditor') +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath=Get-RepositorySolution -RepositoryRoot $repositoryRoot +$projects=@(Get-SolutionProjects -SolutionPath $solutionPath -RepositoryRoot $repositoryRoot) +$executables=@(Get-ExecutableProjects -ProjectPaths $projects -Configuration $Configuration) +$releaseRoot=Join-Path $repositoryRoot 'artifacts/release' +$stageName="$ArchiveBaseName-$Version-$RuntimeIdentifier" +$stageParent=Join-Path $releaseRoot 'stage' +$stage=Join-Path $stageParent $stageName +$archive=Join-Path $releaseRoot "$stageName.zip" +if(Test-Path $stage){Remove-Item $stage -Recurse -Force}; New-Item -ItemType Directory -Path $stage -Force|Out-Null +Invoke-DotNet -Arguments @('restore',$solutionPath,'-r',$RuntimeIdentifier) +foreach($executable in $executables){ + $publish=Join-Path $releaseRoot "publish/$RuntimeIdentifier/$($executable.AssemblyName)" + Invoke-DotNet -Arguments @('publish',$executable.ProjectPath,'-c',$Configuration,'-r',$RuntimeIdentifier,'--no-restore','--self-contained','false','-p:PublishSingleFile=true','-p:PublishTrimmed=false','-p:DebugType=None','-p:DebugSymbols=false','-p:ContinuousIntegrationBuild=true','-o',$publish) + $file=if($RuntimeIdentifier.StartsWith('win-')){"$($executable.AssemblyName).exe"}else{$executable.AssemblyName} + Copy-Item (Join-Path $publish $file) (Join-Path $stage $file) +} +foreach($support in @('README.md','LICENSE')){if(Test-Path (Join-Path $repositoryRoot $support)){Copy-Item (Join-Path $repositoryRoot $support) (Join-Path $stage $support)}} +if($RuntimeIdentifier.StartsWith('win-')){Compress-Archive -LiteralPath $stage -DestinationPath $archive -CompressionLevel Optimal}else{Get-ChildItem $stage -File|Where-Object{$_.Name -in @('lineeditor','ed','red','sed')}|ForEach-Object{& chmod +x $_.FullName}; Push-Location $stageParent;try{& zip -r -q $archive $stageName;if(0 -ne $LASTEXITCODE){throw 'zip failed'}}finally{Pop-Location}} +Write-Host "Created release archive: $archive" diff --git a/packaging/Get-RepositoryMetadata.ps1 b/packaging/Get-RepositoryMetadata.ps1 new file mode 100644 index 0000000..8de6703 --- /dev/null +++ b/packaging/Get-RepositoryMetadata.ps1 @@ -0,0 +1,21 @@ +param([ValidateSet('Debug','Staging','Release')][string]$Configuration='Release',[string]$GitHubOutputPath='') +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath=Get-RepositorySolution -RepositoryRoot $repositoryRoot -AllowMissing +$hasSolution=$null -ne $solutionPath +$hasExecutables=$false +$portableSolutionPath='' +if($hasSolution){ + $projects=@(Get-SolutionProjects -SolutionPath $solutionPath -RepositoryRoot $repositoryRoot) + $hasExecutables=0 -lt @(Get-ExecutableProjects -ProjectPaths $projects -Configuration $Configuration).Count + $portableSolutionPath=[System.IO.Path]::GetRelativePath($repositoryRoot,$solutionPath).Replace([System.IO.Path]::DirectorySeparatorChar,'/') +} +$result=[ordered]@{RepositoryRoot=$repositoryRoot;HasSolution=$hasSolution;SolutionPath=$portableSolutionPath;HasExecutables=$hasExecutables} +if(-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)){ + "has_solution=$($hasSolution.ToString().ToLowerInvariant())" >> $GitHubOutputPath + "solution_path=$portableSolutionPath" >> $GitHubOutputPath + "has_executables=$($hasExecutables.ToString().ToLowerInvariant())" >> $GitHubOutputPath +} +[pscustomobject]$result diff --git a/packaging/Invoke-Build.ps1 b/packaging/Invoke-Build.ps1 new file mode 100644 index 0000000..cefc491 --- /dev/null +++ b/packaging/Invoke-Build.ps1 @@ -0,0 +1,14 @@ +param([ValidateSet('all','clean','restore','build','test','pack','validate')][string]$Section='all',[ValidateSet('Debug','Staging','Release')][string]$Configuration='Debug') +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath=Get-RepositorySolution -RepositoryRoot $repositoryRoot +$artifactDirectory=Join-Path $repositoryRoot 'artifacts' +function Clean { Invoke-DotNet -Arguments @('clean',$solutionPath,'-c',$Configuration) } +function Restore { Invoke-DotNet -Arguments @('restore',$solutionPath) } +function Build { Invoke-DotNet -Arguments @('build',$solutionPath,'-c',$Configuration,'--no-restore') } +function Test { Invoke-DotNet -Arguments @('test',$solutionPath,'-c',$Configuration,'--no-build','--no-restore') } +function Pack { New-Item -ItemType Directory -Path $artifactDirectory -Force|Out-Null; Invoke-DotNet -Arguments @('pack',$solutionPath,'-c',$Configuration,'--no-build','--no-restore','-o',$artifactDirectory) } +function Validate { & (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') -ArtifactDirectory $artifactDirectory -Configuration $Configuration -AllowNoPackages } +switch($Section){'all'{Clean;Restore;Build;Test;Pack;Validate}'clean'{Clean}'restore'{Restore}'build'{Build}'test'{Test}'pack'{Pack}'validate'{Validate}} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..ec9426f --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,7 @@ +# Icod.LineEditor build and packaging workflow + +This repository follows the canonical `uniblab/.github` C#/.NET lifecycle: local wrappers use `Debug`; pull requests use `Staging` on Windows, Linux, and macOS; `main` uses six-runner `Release` validation; and `v` tags use `Release` packaging/publication. + +The metadata helpers discover the root solution and executable projects from MSBuild. With the router project present, release archives contain `lineeditor`, `ed`, `red`, and `sed` (or `.exe` equivalents on Windows) together with the repository README and LICENSE. + +Package verification permits repositories with zero packages. The `Icod.LineEditor.Tools` router is a .NET tool package whose installed command is `lineeditor`; other project packaging remains governed by project metadata. diff --git a/packaging/RepositoryTools.psm1 b/packaging/RepositoryTools.psm1 new file mode 100644 index 0000000..ec3fbb5 --- /dev/null +++ b/packaging/RepositoryTools.psm1 @@ -0,0 +1,72 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-DotNet { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + Write-Host "> dotnet $($Arguments -join ' ')" + & dotnet @Arguments + if (0 -ne $LASTEXITCODE) { throw "dotnet exited with status $LASTEXITCODE." } +} + +function Get-RepositorySolution { + param([Parameter(Mandatory = $true)][string]$RepositoryRoot,[switch]$AllowMissing) + $solutions = @(Get-ChildItem -LiteralPath $RepositoryRoot -File | Where-Object { $_.Extension -in @('.sln', '.slnx') }) + if (0 -eq $solutions.Count -and $AllowMissing) { return $null } + if (1 -ne $solutions.Count) { throw "Expected exactly one root .sln or .slnx file; found $($solutions.Count)." } + return $solutions[0].FullName +} + +function Get-SolutionProjects { + param([Parameter(Mandatory = $true)][string]$SolutionPath,[Parameter(Mandatory = $true)][string]$RepositoryRoot) + $output = @(& dotnet sln $SolutionPath list) + if (0 -ne $LASTEXITCODE) { throw "Unable to list projects in '$SolutionPath'." } + $projects = @() + foreach ($line in $output) { + $candidate = $line.Trim() + if (-not $candidate.EndsWith('.csproj', [System.StringComparison]::OrdinalIgnoreCase)) { continue } + $fullPath = if ([System.IO.Path]::IsPathRooted($candidate)) { $candidate } else { Join-Path $RepositoryRoot $candidate } + $projects += [System.IO.Path]::GetFullPath($fullPath) + } + return $projects +} + +function Get-MSBuildProperty { + param([Parameter(Mandatory = $true)][string]$ProjectPath,[Parameter(Mandatory = $true)][string]$Name,[string]$Configuration = 'Release') + $value = @(& dotnet msbuild $ProjectPath -nologo "-property:Configuration=$Configuration" "-getProperty:$Name") -join "`n" + if (0 -ne $LASTEXITCODE) { throw "Unable to read MSBuild property '$Name' from '$ProjectPath'." } + return $value.Trim() +} + +function Get-ExecutableProjects { + param([Parameter(Mandatory = $true)][string[]]$ProjectPaths,[string]$Configuration = 'Release') + $result = @() + foreach ($projectPath in $ProjectPaths) { + $outputType = Get-MSBuildProperty -ProjectPath $projectPath -Name 'OutputType' -Configuration $Configuration + if ($outputType -in @('Exe', 'WinExe')) { + $assemblyName = Get-MSBuildProperty -ProjectPath $projectPath -Name 'AssemblyName' -Configuration $Configuration + if ([string]::IsNullOrWhiteSpace($assemblyName)) { $assemblyName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath) } + $result += [pscustomobject]@{ ProjectPath = $projectPath; AssemblyName = $assemblyName } + } + } + return $result +} + +function Get-PackageMetadata { + param([Parameter(Mandatory = $true)][string]$PackagePath) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $nuspec = @($archive.Entries | Where-Object { $_.FullName.EndsWith('.nuspec', [System.StringComparison]::OrdinalIgnoreCase) }) + if (1 -ne $nuspec.Count) { throw "Package '$PackagePath' must contain exactly one nuspec." } + $reader = [System.IO.StreamReader]::new($nuspec[0].Open()) + try { [xml]$xml = $reader.ReadToEnd() } finally { $reader.Dispose() } + $metadata = $xml.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']") + return [pscustomobject]@{ + Id = $metadata.SelectSingleNode("*[local-name()='id']").InnerText.Trim() + Version = $metadata.SelectSingleNode("*[local-name()='version']").InnerText.Trim() + Readme = if ($null -eq $metadata.SelectSingleNode("*[local-name()='readme']")) { '' } else { $metadata.SelectSingleNode("*[local-name()='readme']").InnerText.Trim().Replace('\\','/') } + } + } finally { $archive.Dispose() } +} + +Export-ModuleMember -Function @('Invoke-DotNet','Get-RepositorySolution','Get-SolutionProjects','Get-MSBuildProperty','Get-ExecutableProjects','Get-PackageMetadata') diff --git a/packaging/VerifyDistribution.ps1 b/packaging/VerifyDistribution.ps1 new file mode 100644 index 0000000..7dbd960 --- /dev/null +++ b/packaging/VerifyDistribution.ps1 @@ -0,0 +1,14 @@ +param([ValidateSet('Debug','Staging','Release')][string]$Configuration='Release') +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath=Get-RepositorySolution -RepositoryRoot $repositoryRoot +$packageDirectory=Join-Path $repositoryRoot 'artifacts/distribution-validation/packages' +if(Test-Path (Split-Path $packageDirectory -Parent)){Remove-Item (Split-Path $packageDirectory -Parent) -Recurse -Force} +New-Item -ItemType Directory -Path $packageDirectory -Force|Out-Null +Invoke-DotNet -Arguments @('restore',$solutionPath) +Invoke-DotNet -Arguments @('build',$solutionPath,'-c',$Configuration,'--no-restore','-p:ContinuousIntegrationBuild=true') +Invoke-DotNet -Arguments @('test',$solutionPath,'-c',$Configuration,'--no-build','--no-restore','--logger','trx') +Invoke-DotNet -Arguments @('pack',$solutionPath,'-c',$Configuration,'--no-build','--no-restore','-o',$packageDirectory,'-p:ContinuousIntegrationBuild=true') +& (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') -ArtifactDirectory $packageDirectory -Configuration $Configuration -AllowNoPackages diff --git a/packaging/VerifyPackageArtifact.ps1 b/packaging/VerifyPackageArtifact.ps1 new file mode 100644 index 0000000..2ff4d30 --- /dev/null +++ b/packaging/VerifyPackageArtifact.ps1 @@ -0,0 +1,19 @@ +param([Parameter(Mandatory=$true)][string]$ArtifactDirectory,[ValidateSet('Debug','Staging','Release')][string]$Configuration='Release',[string]$ExpectedVersion='',[switch]$AllowNoPackages,[string]$GitHubOutputPath='') +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +if(-not [System.IO.Path]::IsPathRooted($ArtifactDirectory)){$ArtifactDirectory=Join-Path $repositoryRoot $ArtifactDirectory} +$packages=@(Get-ChildItem -LiteralPath $ArtifactDirectory -Filter '*.nupkg' -File | Where-Object {-not $_.Name.EndsWith('.symbols.nupkg')} | Sort-Object Name) +if(-not [string]::IsNullOrWhiteSpace($ExpectedVersion)){$packages=@($packages|Where-Object{(Get-PackageMetadata -PackagePath $_.FullName).Version -eq $ExpectedVersion})} +if(0 -eq $packages.Count -and -not $AllowNoPackages){throw 'No matching NuGet packages were found.'} +Add-Type -AssemblyName System.IO.Compression.FileSystem +foreach($package in $packages){ + $metadata=Get-PackageMetadata -PackagePath $package.FullName + $archive=[System.IO.Compression.ZipFile]::OpenRead($package.FullName) + try{ + if(-not [string]::IsNullOrWhiteSpace($metadata.Readme) -and $null -eq ($archive.Entries|Where-Object{$_.FullName -eq $metadata.Readme}|Select-Object -First 1)){throw "Package '$($package.Name)' declares a missing readme."} + }finally{$archive.Dispose()} +} +if(-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)){"package_count=$($packages.Count)" >> $GitHubOutputPath;"has_packages=$((0 -lt $packages.Count).ToString().ToLowerInvariant())" >> $GitHubOutputPath} +Write-Host "Exact package verification completed successfully for $($packages.Count) package(s) ($Configuration)." From 27d8b6906fc10ea4b71753da7c918ad65a81c12f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:22:15 -0400 Subject: [PATCH 09/29] Add canonical pull-request workflow --- .github/workflows/pull-request.yaml | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/pull-request.yaml diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..15adc59 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,68 @@ +name: pull-request + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_VERSION: 10.0.x + CONFIGURATION: Staging + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + has_solution: ${{ steps.repository.outputs.has_solution }} + solution_path: ${{ steps.repository.outputs.solution_path }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + name: Discover .NET repository + shell: pwsh + run: ./packaging/Get-RepositoryMetadata.ps1 -Configuration '${{ env.CONFIGURATION }}' -GitHubOutputPath $env:GITHUB_OUTPUT + + validate: + needs: metadata + if: needs.metadata.outputs.has_solution == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows + verify_packages: false + - os: ubuntu-latest + name: Linux + verify_packages: true + - os: macos-latest + name: macOS + verify_packages: false + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore + run: dotnet restore '${{ needs.metadata.outputs.solution_path }}' + - name: Build + run: dotnet build '${{ needs.metadata.outputs.solution_path }}' -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - name: Test + run: dotnet test '${{ needs.metadata.outputs.solution_path }}' -c ${{ env.CONFIGURATION }} --no-build --no-restore --logger trx + - name: Pack Staging artifacts + if: matrix.verify_packages + run: dotnet pack '${{ needs.metadata.outputs.solution_path }}' -c ${{ env.CONFIGURATION }} --no-build --no-restore -o artifacts -p:ContinuousIntegrationBuild=true + - name: Verify exact Staging package artifacts + if: matrix.verify_packages + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts -Configuration '${{ env.CONFIGURATION }}' -AllowNoPackages From 5f6323fa4301449f5db423dd160153224c600fe5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:22:31 -0400 Subject: [PATCH 10/29] Add canonical main workflow --- .github/workflows/main.yaml | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/main.yaml diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..a7ca44d --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,62 @@ +name: main + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_VERSION: 10.0.x + CONFIGURATION: Release + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + has_solution: ${{ steps.repository.outputs.has_solution }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + name: Discover .NET repository + shell: pwsh + run: ./packaging/Get-RepositoryMetadata.ps1 -Configuration '${{ env.CONFIGURATION }}' -GitHubOutputPath $env:GITHUB_OUTPUT + + validate: + needs: metadata + if: needs.metadata.outputs.has_solution == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows x64 + - os: windows-11-arm + name: Windows ARM64 + - os: ubuntu-24.04 + name: Linux x64 + - os: ubuntu-24.04-arm + name: Linux ARM64 + - os: macos-15-intel + name: macOS x64 + - os: macos-15 + name: macOS ARM64 + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Verify Release distribution + shell: pwsh + run: ./packaging/VerifyDistribution.ps1 -Configuration '${{ env.CONFIGURATION }}' From e9e30a2308c4ef37a47490f9a669ed784f316b3f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:22:43 -0400 Subject: [PATCH 11/29] Add canonical distribution validation workflow --- .../workflows/distribution-validation.yaml | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/distribution-validation.yaml diff --git a/.github/workflows/distribution-validation.yaml b/.github/workflows/distribution-validation.yaml new file mode 100644 index 0000000..d1be91f --- /dev/null +++ b/.github/workflows/distribution-validation.yaml @@ -0,0 +1,69 @@ +name: distribution-validation + +on: + workflow_dispatch: + inputs: + configuration: + description: Build configuration + required: true + default: Release + type: choice + options: + - Debug + - Staging + - Release + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_VERSION: 10.0.x + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + has_solution: ${{ steps.repository.outputs.has_solution }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + name: Discover .NET repository + shell: pwsh + run: ./packaging/Get-RepositoryMetadata.ps1 -Configuration '${{ inputs.configuration }}' -GitHubOutputPath $env:GITHUB_OUTPUT + + validate: + needs: metadata + if: needs.metadata.outputs.has_solution == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows x64 + - os: windows-11-arm + name: Windows ARM64 + - os: ubuntu-24.04 + name: Linux x64 + - os: ubuntu-24.04-arm + name: Linux ARM64 + - os: macos-15-intel + name: macOS x64 + - os: macos-15 + name: macOS ARM64 + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Verify distribution + shell: pwsh + run: ./packaging/VerifyDistribution.ps1 -Configuration '${{ inputs.configuration }}' From 79965ef608112db0c6d24c5fb4198ecb65edc7f0 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:22:57 -0400 Subject: [PATCH 12/29] Add release package selection helper --- packaging/SelectReleasePackages.ps1 | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packaging/SelectReleasePackages.ps1 diff --git a/packaging/SelectReleasePackages.ps1 b/packaging/SelectReleasePackages.ps1 new file mode 100644 index 0000000..be718a6 --- /dev/null +++ b/packaging/SelectReleasePackages.ps1 @@ -0,0 +1,53 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SourceDirectory, + + [Parameter(Mandatory = $true)] + [string]$DestinationDirectory, + + [Parameter(Mandatory = $true)] + [string]$ExpectedVersion, + + [string]$GitHubOutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force + +foreach ($variableName in @('SourceDirectory', 'DestinationDirectory')) { + $value = Get-Variable -Name $variableName -ValueOnly + if (-not [System.IO.Path]::IsPathRooted($value)) { + $value = Join-Path $repositoryRoot $value + } + Set-Variable -Name $variableName -Value ([System.IO.Path]::GetFullPath($value)) +} + +if (Test-Path -LiteralPath $DestinationDirectory) { + Remove-Item -LiteralPath $DestinationDirectory -Recurse -Force +} +New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null + +$selected = @() +$packages = @( + Get-ChildItem -LiteralPath $SourceDirectory -Filter '*.nupkg' -File | + Where-Object { -not $_.Name.EndsWith('.symbols.nupkg', [System.StringComparison]::OrdinalIgnoreCase) } | + Sort-Object Name +) +foreach ($package in $packages) { + $metadata = Get-PackageMetadata -PackagePath $package.FullName + if ($metadata.Version -ne $ExpectedVersion) { + continue + } + Copy-Item -LiteralPath $package.FullName -Destination (Join-Path $DestinationDirectory $package.Name) + $selected += $package.Name +} + +$hasPackages = 0 -lt $selected.Count +if (-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)) { + "has_packages=$($hasPackages.ToString().ToLowerInvariant())" >> $GitHubOutputPath + "package_count=$($selected.Count)" >> $GitHubOutputPath +} +Write-Host "Selected $($selected.Count) package(s) for release $ExpectedVersion." From 471f9bdcbab5e5543bff37633fe2c569250946c7 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:23:30 -0400 Subject: [PATCH 13/29] Add canonical release workflow --- .github/workflows/release.yaml | 219 +++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..177ca5e --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,219 @@ +name: release + +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + DOTNET_VERSION: 10.0.x + CONFIGURATION: Release + RELEASE_DIRECTORY: artifacts/release + SELECTED_PACKAGE_DIRECTORY: artifacts/release-packages + PACKAGE_ARTIFACT: nuget-packages + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + solution_path: ${{ steps.repository.outputs.solution_path }} + has_executables: ${{ steps.repository.outputs.has_executables }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + shell: pwsh + run: ./packaging/Get-RepositoryMetadata.ps1 -Configuration '${{ env.CONFIGURATION }}' -GitHubOutputPath $env:GITHUB_OUTPUT + - name: Require tagged commit on default branch + shell: pwsh + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git fetch origin $env:DEFAULT_BRANCH --no-tags + git merge-base --is-ancestor $env:GITHUB_SHA "origin/$env:DEFAULT_BRANCH" + if (0 -ne $LASTEXITCODE) { throw "Release tag is not contained in '$env:DEFAULT_BRANCH'." } + - id: version + shell: pwsh + run: | + if ($env:GITHUB_REF_NAME -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)$') { + throw "Unsupported release tag '$env:GITHUB_REF_NAME'." + } + $version = $Matches.version + "version=$version" >> $env:GITHUB_OUTPUT + "prerelease=$($version.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + + package: + needs: metadata + runs-on: ubuntu-latest + outputs: + has_packages: ${{ steps.select.outputs.has_packages }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - run: dotnet restore '${{ needs.metadata.outputs.solution_path }}' + - run: dotnet build '${{ needs.metadata.outputs.solution_path }}' -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - run: dotnet pack '${{ needs.metadata.outputs.solution_path }}' -c ${{ env.CONFIGURATION }} --no-build --no-restore -o ${{ env.RELEASE_DIRECTORY }} -p:ContinuousIntegrationBuild=true + - id: select + shell: pwsh + run: ./packaging/SelectReleasePackages.ps1 -SourceDirectory '${{ env.RELEASE_DIRECTORY }}' -DestinationDirectory '${{ env.SELECTED_PACKAGE_DIRECTORY }}' -ExpectedVersion '${{ needs.metadata.outputs.version }}' -GitHubOutputPath $env:GITHUB_OUTPUT + - name: Verify release packages + if: steps.select.outputs.has_packages == 'true' + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory '${{ env.SELECTED_PACKAGE_DIRECTORY }}' -Configuration '${{ env.CONFIGURATION }}' -ExpectedVersion '${{ needs.metadata.outputs.version }}' + - name: Upload release packages + if: steps.select.outputs.has_packages == 'true' + uses: actions/upload-artifact@v4 + with: + name: ${{ env.PACKAGE_ARTIFACT }} + path: ${{ env.SELECTED_PACKAGE_DIRECTORY }}/*.nupkg + if-no-files-found: error + + archives: + needs: metadata + if: needs.metadata.outputs.has_executables == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + - os: windows-11-arm + rid: win-arm64 + - os: ubuntu-24.04 + rid: linux-x64 + - os: ubuntu-24.04-arm + rid: linux-arm64 + - os: macos-15-intel + rid: osx-x64 + - os: macos-15 + rid: osx-arm64 + runs-on: ${{ matrix.os }} + name: ${{ matrix.rid }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - shell: pwsh + run: ./packaging/BuildReleaseArchive.ps1 -Configuration '${{ env.CONFIGURATION }}' -RuntimeIdentifier '${{ matrix.rid }}' -Version '${{ needs.metadata.outputs.version }}' + - uses: actions/upload-artifact@v4 + with: + name: release-archive-${{ matrix.rid }} + path: ${{ env.RELEASE_DIRECTORY }}/Icod.LineEditor-${{ needs.metadata.outputs.version }}-${{ matrix.rid }}.zip + if-no-files-found: error + + publish-nuget: + needs: [metadata, package] + if: needs.package.outputs.has_packages == 'true' + runs-on: ubuntu-latest + environment: Release + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/download-artifact@v4 + with: + name: ${{ env.PACKAGE_ARTIFACT }} + path: artifacts/package + - id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: | + foreach ($package in Get-ChildItem artifacts/package -Filter '*.nupkg' -File) { + dotnet nuget push $package.FullName --api-key $env:NUGET_API_KEY --source 'https://api.nuget.org/v3/index.json' --skip-duplicate + if (0 -ne $LASTEXITCODE) { throw "NuGet publication failed." } + } + + publish-github-packages: + needs: [metadata, package] + if: needs.package.outputs.has_packages == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/download-artifact@v4 + with: + name: ${{ env.PACKAGE_ARTIFACT }} + path: artifacts/package + - shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_OWNER: ${{ github.repository_owner }} + run: | + $source = "https://nuget.pkg.github.com/$env:GITHUB_OWNER/index.json" + foreach ($package in Get-ChildItem artifacts/package -Filter '*.nupkg' -File) { + dotnet nuget push $package.FullName --source $source --api-key $env:GITHUB_TOKEN --skip-duplicate + if (0 -ne $LASTEXITCODE) { throw "GitHub Packages publication failed." } + } + + github-release: + needs: [metadata, package, archives, publish-nuget, publish-github-packages] + if: >- + always() && + needs.metadata.result == 'success' && + needs.package.result == 'success' && + (needs.archives.result == 'success' || needs.archives.result == 'skipped') && + (needs.publish-nuget.result == 'success' || needs.publish-nuget.result == 'skipped') && + (needs.publish-github-packages.result == 'success' || needs.publish-github-packages.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download packages + if: needs.package.outputs.has_packages == 'true' + uses: actions/download-artifact@v4 + with: + name: ${{ env.PACKAGE_ARTIFACT }} + path: artifacts/release-assets + - name: Download archives + if: needs.metadata.outputs.has_executables == 'true' + uses: actions/download-artifact@v4 + with: + pattern: release-archive-* + path: artifacts/release-assets + merge-multiple: true + - name: Create checksums + shell: pwsh + run: | + New-Item -ItemType Directory artifacts/release-assets -Force | Out-Null + $files = @(Get-ChildItem artifacts/release-assets -File | Where-Object { $_.Extension -in @('.zip','.nupkg') } | Sort-Object Name) + if (0 -eq $files.Count) { throw 'No release assets were produced.' } + $lines = foreach ($file in $files) { "$((Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) $($file.Name)" } + [System.IO.File]::WriteAllLines('artifacts/release-assets/SHA256SUMS.txt',$lines,[System.Text.UTF8Encoding]::new($false)) + - name: Create GitHub Release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + $args = @('release','create',$env:GITHUB_REF_NAME,'--verify-tag','--title',"Icod.LineEditor ${{ needs.metadata.outputs.version }}",'--generate-notes') + if ('true' -eq '${{ needs.metadata.outputs.prerelease }}') { $args += @('--prerelease','--latest=false') } + $args += @(Get-ChildItem artifacts/release-assets -File | Sort-Object Name | ForEach-Object { $_.FullName }) + & gh @args + if (0 -ne $LASTEXITCODE) { throw 'GitHub Release creation failed.' } From c4625277f1fb9a0932772b9fdeeaf24890962571 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:23:50 -0400 Subject: [PATCH 14/29] Remove legacy pull-request workflow --- .github/workflows/pr-build-and-test.yaml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/pr-build-and-test.yaml diff --git a/.github/workflows/pr-build-and-test.yaml b/.github/workflows/pr-build-and-test.yaml deleted file mode 100644 index b73b883..0000000 --- a/.github/workflows/pr-build-and-test.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: pr-build-and-test - -on: - pull_request: - -permissions: - contents: read - -jobs: - build-and-test: - strategy: - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - run: dotnet clean Icod.LineEditor.sln -c Staging - - run: dotnet restore Icod.LineEditor.sln - - run: dotnet build Icod.LineEditor.sln -c Staging --no-restore - - run: dotnet test Icod.LineEditor.sln -c Staging --no-build --logger trx \ No newline at end of file From bd12e655ca72a2fa64ddec267df3dff7b5b8dfa5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:23:59 -0400 Subject: [PATCH 15/29] Remove legacy main workflow --- .github/workflows/push-main.yaml | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/push-main.yaml diff --git a/.github/workflows/push-main.yaml b/.github/workflows/push-main.yaml deleted file mode 100644 index 58420e7..0000000 --- a/.github/workflows/push-main.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: push-main - -on: - push: - branches: - - main - -permissions: - contents: read - -jobs: - build-and-test: - strategy: - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - run: dotnet clean Icod.LineEditor.sln -c Release - - run: dotnet restore Icod.LineEditor.sln - - run: dotnet build Icod.LineEditor.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true - - run: dotnet test Icod.LineEditor.sln -c Release --no-build --logger trx \ No newline at end of file From 3654f2b3b521cc2a9613fe4c2e40cf547249969e Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:24:35 -0400 Subject: [PATCH 16/29] Document lineeditor router and normalized lifecycle --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index aa624cd..880f35d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Icod.LineEditor +[![PR Staging build](https://github.com/uniblab/Icod.LineEditor/actions/workflows/pull-request.yaml/badge.svg?event=pull_request)](https://github.com/uniblab/Icod.LineEditor/actions/workflows/pull-request.yaml) +[![Main Release validation](https://github.com/uniblab/Icod.LineEditor/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/uniblab/Icod.LineEditor/actions/workflows/main.yaml) + `Icod.LineEditor` is a managed .NET implementation of the classic Unix line-editing family: `ed`, its restricted form `red`, and the `sed` stream editor. The repository targets .NET 10 and C# 13 and is designed for Windows, Linux, and macOS. The editors use managed code for their editing, regular-expression, record-processing, and command orchestration behavior rather than invoking the host system's `ed`, `red`, or `sed` executable. @@ -10,10 +13,21 @@ This repository is the permanent home of the LineEditor family extracted from th | Command | Purpose | |---|---| +| [`lineeditor`](lineeditor/README.md) | Distribution router that directly multiplexes the managed `ed`, `red`, and `sed` commands. | | [`ed`](ed/README.md) | Interactive and scriptable line-oriented text editor, following the GNU ed 1.22.5 compatibility profile. | | [`red`](red/README.md) | Permanently restricted `ed` profile that disables shell execution and limits pathname syntax. | | [`sed`](sed/README.md) | Non-interactive stream editor with GNU-style addressing, substitutions, branching, hold space, in-place editing, sandboxing, and NUL-delimited records. | +The router supports: + +```text +lineeditor ed [OPTION]... +lineeditor red [OPTION]... +lineeditor sed [OPTION]... +``` + +It calls the managed command implementations directly and does not spawn the standalone executables. The standalone `ed`, `red`, and `sed` programs remain first-class build and release outputs. + Each executable directory contains a dedicated man-page-style `README.md` describing its implemented command-line profile, behavior, exit status, platform notes, authorship, and licensing. ## `Icod.LineEditor.Ed.Shared` @@ -53,9 +67,14 @@ Published neutral foundation Icod.CommandFramework 1.1.0 ↓ PackageReference sed + + ed red sed + \ | / + lineeditor + distribution router ``` -`ed` and `red` deliberately share the Ed engine through repository-local `ProjectReference` relationships. `sed` remains a separate execution engine. No production project in this repository references `Icod.CoreUtils.Shared`. +`ed` and `red` deliberately share the Ed engine through repository-local `ProjectReference` relationships. `sed` remains a separate execution engine. The `lineeditor` router references all three command projects only to provide in-process dispatch. No production project in this repository references `Icod.CoreUtils.Shared`. ## Compatibility philosophy @@ -97,25 +116,35 @@ On Unix-like hosts: ./build.sh ``` +With no section argument, the wrappers use `Debug` and run: + +```text +clean → restore → build → test → pack → validate +``` + +The individual `clean`, `restore`, `build`, `test`, `pack`, and `validate` stages may also be requested. + Or build the solution directly: ```text dotnet restore Icod.LineEditor.sln dotnet build Icod.LineEditor.sln -c Staging --no-restore -dotnet test Icod.LineEditor.sln -c Staging --no-build +dotnet test Icod.LineEditor.sln -c Staging --no-build --no-restore ``` The solution defines `Debug`, `Staging`, and `Release` configurations. Release builds treat compiler warnings as errors except for documentation warning `CS1591`. -## Continuous integration +## Continuous integration and release + +The repository follows the canonical `uniblab/.github` lifecycle: -Pull requests and pushes to `main` are built and tested with .NET 10 on: +- pull requests build and test `Staging` on Windows, Linux, and macOS; Linux additionally packs and verifies generated NuGet artifacts; +- pushes to `main` run `Release` distribution validation on Windows/Linux/macOS for x64 and ARM64; and +- `v` tags contained in the default branch run the `Release` package/archive publication graph. -- `windows-latest` -- `ubuntu-latest` -- `macos-latest` +Executable release archives are produced for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, and `osx-arm64`. Each archive contains `lineeditor`, `ed`, `red`, and `sed` (with `.exe` suffixes on Windows), plus the repository `README.md` and `LICENSE`. -The `main` workflow builds the `Release` configuration with `ContinuousIntegrationBuild=true` before running the complete test suite. +The `Icod.LineEditor.Tools` router is packable as a .NET tool whose installed command is `lineeditor`. Package publication is version-gated by the actual generated nuspec version. See [`packaging/README.md`](packaging/README.md) for the build and distribution contract. ## Project layout @@ -125,7 +154,9 @@ Icod.LineEditor/ ├── ed/ standard line editor ├── red/ restricted line editor ├── sed/ stream editor -├── tests/ command and engine tests +├── lineeditor/ ed/red/sed command router +├── tests/ command, engine, and router tests +├── packaging/ normalized build/distribution helpers ├── docs/history/ retained architecture and migration history ├── Icod.LineEditor.sln ├── build.cmd @@ -136,6 +167,7 @@ Icod.LineEditor/ The executable READMEs are intended to function much like concise manual pages: +- [`lineeditor/README.md`](lineeditor/README.md) - [`ed/README.md`](ed/README.md) - [`red/README.md`](red/README.md) - [`sed/README.md`](sed/README.md) @@ -144,11 +176,11 @@ For the reusable Ed-family engine, see [`Icod.LineEditor.Ed.Shared/README.md`](I ## Licensing -The executable tools `ed`, `red`, and `sed` are distributed under the GNU General Public License, version 3 or later. Each tool directory contains its own `LICENSE` file, and the repository root [`LICENSE`](LICENSE) contains the same GPL text. +The executable tools `lineeditor`, `ed`, `red`, and `sed` are distributed under the GNU General Public License, version 3 or later. Each standalone tool directory contains its own `LICENSE` file, and the repository root [`LICENSE`](LICENSE) contains the same GPL text used by the router distribution. `Icod.LineEditor.Ed.Shared` is distributed under the GNU Lesser General Public License, version 3 or later. See [`Icod.LineEditor.Ed.Shared/LICENSE`](Icod.LineEditor.Ed.Shared/LICENSE). -The build projects copy their local `README.md` and `LICENSE` into the output directory as `$(AssemblyName).README.md` and `$(AssemblyName).LICENSE.txt` respectively. +The build projects copy their local `README.md` and `LICENSE` into the output directory as `$(AssemblyName).README.md` and `$(AssemblyName).LICENSE.txt` respectively where configured. ## Upstream inspiration and authorship From ef5353a06b248a836ebfe1f140b36f6ee12c7200 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:34:16 -0400 Subject: [PATCH 17/29] Use root README for router package --- lineeditor/Icod.LineEditor.Router.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lineeditor/Icod.LineEditor.Router.csproj b/lineeditor/Icod.LineEditor.Router.csproj index d9811d2..c32591f 100644 --- a/lineeditor/Icod.LineEditor.Router.csproj +++ b/lineeditor/Icod.LineEditor.Router.csproj @@ -24,7 +24,7 @@ - + From 73dfeabc3291179cdd7a65897aa924864b7751ab Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:35:16 -0400 Subject: [PATCH 18/29] Expand lineeditor router documentation --- lineeditor/README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/lineeditor/README.md b/lineeditor/README.md index e20d0a2..22fe574 100644 --- a/lineeditor/README.md +++ b/lineeditor/README.md @@ -1,11 +1,46 @@ -# Icod.LineEditor.Tools +# `lineeditor` router -`lineeditor` is the distribution router for the managed Icod.LineEditor command suite. +`lineeditor` is the distribution router for the managed `Icod.LineEditor` command suite. It multiplexes `ed`, `red`, and `sed` without spawning the standalone executables. + +## Usage + +```text +lineeditor COMMAND [OPTION]... [ARG]... +``` + +Supported commands are: ```text -lineeditor ed [OPTION]... +lineeditor ed [OPTION]... lineeditor red [OPTION]... lineeditor sed [OPTION]... ``` -The router dispatches directly to the managed command implementations and does not spawn the standalone executables. The standalone `ed`, `red`, and `sed` commands remain first-class build and release outputs. +Router options are: + +```text +-h, --help display router help and exit +-V, --version display the router version and exit +``` + +With no command, or with an unknown command, the router writes a usage diagnostic to standard error and exits with status `2`. + +## Dispatch model + +The router references the managed `ed`, `red`, and `sed` projects directly and invokes their command entry points in process. Command arguments and caller-owned standard streams are forwarded to the selected command, and the selected command's exit status is returned by the router. + +The router is therefore a distribution convenience, not a replacement implementation. The standalone `ed`, `red`, and `sed` executables remain first-class build, test, and release outputs. + +## Package + +The NuGet package identity is `Icod.LineEditor.Tools`; its installed command is `lineeditor`. + +The package intentionally uses the repository root [`README.md`](../README.md) as its NuGet package README so package consumers receive the complete suite overview, installation/distribution contract, command inventory, compatibility notes, and licensing information. This file remains the router-specific repository documentation. + +## Runtime + +The router targets .NET 10 and is intended for Windows, Linux, and macOS. It uses the same managed implementations and dependencies as the standalone commands. + +## Licensing + +The router is distributed under the GNU General Public License, version 3 or later. See the repository root [`LICENSE`](../LICENSE). From 368d5394e0a2cdaa8c7d0942d27a84b51e96643b Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:35:44 -0400 Subject: [PATCH 19/29] Document normalized packaging lifecycle --- packaging/README.md | 177 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 3 deletions(-) diff --git a/packaging/README.md b/packaging/README.md index ec9426f..41dd501 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,7 +1,178 @@ # Icod.LineEditor build and packaging workflow -This repository follows the canonical `uniblab/.github` C#/.NET lifecycle: local wrappers use `Debug`; pull requests use `Staging` on Windows, Linux, and macOS; `main` uses six-runner `Release` validation; and `v` tags use `Release` packaging/publication. +This repository follows the canonical `uniblab/.github` C#/.NET build and release pattern. -The metadata helpers discover the root solution and executable projects from MSBuild. With the router project present, release archives contain `lineeditor`, `ed`, `red`, and `sed` (or `.exe` equivalents on Windows) together with the repository README and LICENSE. +## Validation ladder -Package verification permits repositories with zero packages. The `Icod.LineEditor.Tools` router is a .NET tool package whose installed command is `lineeditor`; other project packaging remains governed by project metadata. +| Lifecycle | Configuration | Work | +| --- | --- | --- | +| local `build.cmd` / `build.sh` | `Debug` | clean, restore, build, test, pack, exact package validation | +| pull request | `Staging` | Windows/Linux/macOS build and test; Linux also packs and verifies NuGet artifacts | +| default branch | `Release` | six-runner Windows/Linux/macOS x64/ARM64 distribution validation | +| `v` tag | `Release` | package selection/publication, six RID archives, checksums, GitHub Release | + +The workflows and scripts are metadata driven. The root solution and executable projects are discovered from the repository and MSBuild rather than hard-coded from repository names. + +## Repository topology + +Production projects are: + +- `Icod.LineEditor.Ed.Shared` — repository-local Ed/Red implementation library; +- `ed` — standalone line editor; +- `red` — standalone restricted line editor; +- `sed` — standalone stream editor; and +- `lineeditor` — distribution router for `ed`, `red`, and `sed`. + +The router project identity is `Icod.LineEditor.Router`; its assembly and executable name are `lineeditor`. Its NuGet package identity is `Icod.LineEditor.Tools`, and the installed .NET tool command is `lineeditor`. + +The `Icod.LineEditor.Tools` package uses the repository root `README.md` as `PackageReadmeFile`. The router-specific `lineeditor/README.md` remains repository documentation and is not the NuGet package README. + +## Shared scripts + +### `RepositoryTools.psm1` + +Provides common helpers for locating the root solution, enumerating solution projects, reading MSBuild properties, discovering executable projects, and inspecting generated NuGet metadata. + +### `Get-RepositoryMetadata.ps1` + +Reports whether the repository has a root solution, its repository-relative path, and whether executable projects are present. Repository-relative solution paths are used so metadata produced on Linux can be consumed safely by Windows and macOS jobs. + +### `Invoke-Build.ps1` + +Implements the local build contract used by `build.cmd` and `build.sh`. With no section argument the wrappers use `Debug` and run: + +```text +clean → restore → build → test → pack → validate +``` + +Individual stages may be requested as `clean`, `restore`, `build`, `test`, `pack`, or `validate`. + +### `VerifyPackageArtifact.ps1` + +Validates generated `.nupkg` files supplied by the caller. It verifies package metadata, declared package README presence, and .NET tool metadata shape where applicable. The script supports repositories in which a given configuration legitimately produces no packages. + +### `VerifyDistribution.ps1` + +Runs the common source-tree distribution gate: + +1. restore; +2. build; +3. test; +4. pack without rebuilding; and +5. exact package validation. + +This is the authoritative validation used by the six-runner `main` and manually dispatched distribution-validation workflows. + +### `SelectReleasePackages.ps1` + +Selects only generated packages whose actual nuspec version matches the `v` tag version. A mismatched package is skipped rather than published accidentally. + +### `BuildReleaseArchive.ps1` + +Discovers executable projects through MSBuild and publishes them as framework-dependent single-file executables. With the router present, each RID archive contains: + +```text +lineeditor +ed +red +sed +README.md +LICENSE +``` + +Windows executable names use the `.exe` suffix. + +The six release RIDs are: + +```text +win-x64 +win-arm64 +linux-x64 +linux-arm64 +osx-x64 +osx-arm64 +``` + +## Pull-request validation + +`.github/workflows/pull-request.yaml` uses the `Staging` configuration on: + +- `windows-latest`; +- `ubuntu-latest`; and +- `macos-latest`. + +All three runners restore, build, and test the solution. Linux additionally packs the solution and performs exact NuGet artifact validation. + +## Main-branch validation + +`.github/workflows/main.yaml` uses the `Release` configuration across: + +- Windows x64; +- Windows ARM64; +- Linux x64; +- Linux ARM64; +- macOS x64; and +- macOS ARM64. + +Each runner executes `VerifyDistribution.ps1` so the Release configuration is independently validated on every supported host/architecture combination. + +## Tagged release graph + +A `v` tag starts `.github/workflows/release.yaml`. The tagged commit must be contained in the repository default branch. + +The release graph is intentionally split so package production and executable archives do not depend on one another unnecessarily: + +```text +metadata + ├── package + │ ├── publish-nuget + │ └── publish-github-packages + └── archives (6 RIDs) + +publish-nuget ────────────────┐ +publish-github-packages ──────┼── github-release +archives ─────────────────────┘ +``` + +Only packages whose nuspec version matches the release tag are selected. NuGet.org and GitHub Packages consume the same selected package artifact and use `--skip-duplicate`, allowing safe retries after partial publication. + +GitHub Release creation waits for all applicable package-publication and archive jobs, writes `SHA256SUMS.txt`, and attaches the selected NuGet packages plus all six executable archives. + +## NuGet Trusted Publishing + +NuGet.org publication requires: + +- a GitHub environment named `Release`; +- an Actions secret named `NUGET_USER`; and +- a NuGet.org Trusted Publishing policy authorizing repository `uniblab/Icod.LineEditor`, workflow `release.yaml`, and environment `Release`. + +The package scope must authorize the package actually being published. For the router distribution that package ID is: + +```text +Icod.LineEditor.Tools +``` + +GitHub Packages publication uses the job-scoped `GITHUB_TOKEN` with `packages: write` permission. + +## Package/readme contract + +The router package declares: + +```text +PackageId: Icod.LineEditor.Tools +ToolCommandName: lineeditor +PackageReadme: README.md +``` + +That `README.md` is sourced from the repository root and packed at the NuGet package root. This keeps the package landing page aligned with the complete repository-level suite documentation rather than the narrower router-only README. + +## Release checklist + +Before pushing a release tag: + +1. confirm the intended package/version metadata is present in MSBuild; +2. confirm `lineeditor --version` and command documentation are consistent with that release; +3. confirm the root README installation examples and package identity are current; +4. confirm PR Staging validation is green; +5. merge to `main` and require the six-runner Release validation to pass; and +6. only then push the matching `v` tag. From 1852b1121eb7fae1a4250b914b86907da65d062c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:36:25 -0400 Subject: [PATCH 20/29] Complete package and distribution documentation --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 880f35d..192a626 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,44 @@ lineeditor sed [OPTION]... It calls the managed command implementations directly and does not spawn the standalone executables. The standalone `ed`, `red`, and `sed` programs remain first-class build and release outputs. -Each executable directory contains a dedicated man-page-style `README.md` describing its implemented command-line profile, behavior, exit status, platform notes, authorship, and licensing. +## Installation and distribution + +The distribution router is published as the .NET tool package `Icod.LineEditor.Tools`. Install the current published version with: + +```text +dotnet tool install --global Icod.LineEditor.Tools +``` + +The installed command is: + +```text +lineeditor +``` + +Use the router to select an editor: + +```text +lineeditor ed --help +lineeditor red --help +lineeditor sed --help +``` + +A missing or unknown router command is a usage error. `lineeditor --help` lists the supported commands and `lineeditor --version` reports the router version. Once a command is selected, arguments and standard streams are passed directly to the managed command implementation and its exit status is returned. + +Tagged releases also provide framework-dependent ZIP archives for Windows, Linux, and macOS on x64 and ARM64. Each archive contains all four executable entry points: + +```text +lineeditor +ed +red +sed +``` + +Windows archive entries use the `.exe` suffix. Archives also contain the repository `README.md` and `LICENSE` and require the .NET 10 runtime. + +**This repository root `README.md` is also the NuGet package README for `Icod.LineEditor.Tools`.** The router project packs this file at the package root as `README.md`, so the NuGet landing page and repository overview share the same installation, architecture, compatibility, and licensing documentation. + +The narrower [`lineeditor/README.md`](lineeditor/README.md) documents the router itself; it is not the NuGet package README. ## `Icod.LineEditor.Ed.Shared` @@ -144,7 +181,9 @@ The repository follows the canonical `uniblab/.github` lifecycle: Executable release archives are produced for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, and `osx-arm64`. Each archive contains `lineeditor`, `ed`, `red`, and `sed` (with `.exe` suffixes on Windows), plus the repository `README.md` and `LICENSE`. -The `Icod.LineEditor.Tools` router is packable as a .NET tool whose installed command is `lineeditor`. Package publication is version-gated by the actual generated nuspec version. See [`packaging/README.md`](packaging/README.md) for the build and distribution contract. +The `Icod.LineEditor.Tools` router is a .NET tool package whose installed command is `lineeditor`. Package publication is version-gated by the actual generated nuspec version. NuGet.org Trusted Publishing must authorize the `Icod.LineEditor.Tools` package for repository `uniblab/Icod.LineEditor`, workflow `release.yaml`, and environment `Release`. + +See [`packaging/README.md`](packaging/README.md) for the complete build, validation, archive, package-publication, and release contract. ## Project layout @@ -180,7 +219,7 @@ The executable tools `lineeditor`, `ed`, `red`, and `sed` are distributed under `Icod.LineEditor.Ed.Shared` is distributed under the GNU Lesser General Public License, version 3 or later. See [`Icod.LineEditor.Ed.Shared/LICENSE`](Icod.LineEditor.Ed.Shared/LICENSE). -The build projects copy their local `README.md` and `LICENSE` into the output directory as `$(AssemblyName).README.md` and `$(AssemblyName).LICENSE.txt` respectively where configured. +The `Icod.LineEditor.Tools` NuGet package includes the repository root `README.md` and `LICENSE`. Runtime-specific executable archives likewise include the root `README.md` and `LICENSE` alongside `lineeditor`, `ed`, `red`, and `sed`. ## Upstream inspiration and authorship From d73517eab9ca90179aea9da2185f3c34cade9b00 Mon Sep 17 00:00:00 2001 From: uniblab Date: Thu, 3 Sep 2026 04:42:01 -0400 Subject: [PATCH 21/29] update license prune fiule --- Icod.LineEditor-G7-bootstrap.patch | 19883 --------------------------- Icod.LineEditor.Ed.Shared/LICENSE | 921 +- 2 files changed, 758 insertions(+), 20046 deletions(-) delete mode 100644 Icod.LineEditor-G7-bootstrap.patch diff --git a/Icod.LineEditor-G7-bootstrap.patch b/Icod.LineEditor-G7-bootstrap.patch deleted file mode 100644 index 71f88c2..0000000 --- a/Icod.LineEditor-G7-bootstrap.patch +++ /dev/null @@ -1,19883 +0,0 @@ -diff --git a/.editorconfig b/.editorconfig -new file mode 100644 -index 0000000000000000000000000000000000000000..4dd660decdc2e0f973998b10e0f42e4b43ae7148 ---- /dev/null -+++ b/.editorconfig -@@ -0,0 +1,48 @@ -+root = true -+ -+[*] -+end_of_line = lf -+insert_final_newline = true -+trim_trailing_whitespace = true -+charset = utf-8 -+indent_style = space -+indent_size = 4 -+quote_type = double -+max_line_length = 120 -+ -+[*.{cs,csproj,props,targets,xml}] -+end_of_line = crlf -+insert_final_newline = false -+trim_trailing_whitespace = true -+charset = utf-8 -+indent_style = tab -+indent_size = 4 -+tab_width = 4 -+ -+[*.cs] -+# C# style preferences -+csharp_new_line_before_open_brace = all:warning -+csharp_prefer_braces = true:warning -+csharp_style_expression_bodied_methods = when_possible:suggestion -+csharp_style_expression_bodied_properties = when_possible:suggestion -+csharp_style_namespace_declarations = file_scoped:suggestion -+csharp_style_var_elsewhere = false:suggestion -+csharp_style_var_when_type_is_apparent = true:suggestion -+dotnet_analyzer_diagnostic.category-Style.severity = none -+dotnet_diagnostic.CA1016.severity = none -+dotnet_style_qualification_for_event = false:suggestion -+dotnet_style_qualification_for_field = false:suggestion -+dotnet_style_qualification_for_method = false:suggestion -+dotnet_style_qualification_for_property = false:suggestion -+ -+# Naming conventions -+dotnet_naming_rule.types_should_be_pascal_case.severity = warning -+dotnet_naming_rule.types_should_be_pascal_case.symbols = all_types -+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case_style -+ -+dotnet_naming_symbols.all_types.applicable_kinds = class, struct, interface, enum, delegate -+ -+dotnet_naming_style.pascal_case_style.capitalization = pascal_case -+ -+[**/tests/**] -+max_line_length = off -diff --git a/.github/workflows/pr-build-and-test.yaml b/.github/workflows/pr-build-and-test.yaml -new file mode 100644 -index 0000000000000000000000000000000000000000..b73b8836d0858ea657e7f06510f1943aee8095a8 ---- /dev/null -+++ b/.github/workflows/pr-build-and-test.yaml -@@ -0,0 +1,23 @@ -+name: pr-build-and-test -+ -+on: -+ pull_request: -+ -+permissions: -+ contents: read -+ -+jobs: -+ build-and-test: -+ strategy: -+ matrix: -+ os: [windows-latest, ubuntu-latest, macos-latest] -+ runs-on: ${{ matrix.os }} -+ steps: -+ - uses: actions/checkout@v4 -+ - uses: actions/setup-dotnet@v4 -+ with: -+ dotnet-version: 10.0.x -+ - run: dotnet clean Icod.LineEditor.sln -c Staging -+ - run: dotnet restore Icod.LineEditor.sln -+ - run: dotnet build Icod.LineEditor.sln -c Staging --no-restore -+ - run: dotnet test Icod.LineEditor.sln -c Staging --no-build --logger trx -\ No newline at end of file -diff --git a/.github/workflows/push-main.yaml b/.github/workflows/push-main.yaml -new file mode 100644 -index 0000000000000000000000000000000000000000..58420e7f5b6e0f1fb7d4e07c50ab161705b976a9 ---- /dev/null -+++ b/.github/workflows/push-main.yaml -@@ -0,0 +1,25 @@ -+name: push-main -+ -+on: -+ push: -+ branches: -+ - main -+ -+permissions: -+ contents: read -+ -+jobs: -+ build-and-test: -+ strategy: -+ matrix: -+ os: [windows-latest, ubuntu-latest, macos-latest] -+ runs-on: ${{ matrix.os }} -+ steps: -+ - uses: actions/checkout@v4 -+ - uses: actions/setup-dotnet@v4 -+ with: -+ dotnet-version: 10.0.x -+ - run: dotnet clean Icod.LineEditor.sln -c Release -+ - run: dotnet restore Icod.LineEditor.sln -+ - run: dotnet build Icod.LineEditor.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true -+ - run: dotnet test Icod.LineEditor.sln -c Release --no-build --logger trx -\ No newline at end of file -diff --git a/G7-EXTRACTION-PROVENANCE.md b/G7-EXTRACTION-PROVENANCE.md -new file mode 100644 -index 0000000000000000000000000000000000000000..2fc98a5d5c8251cc34e5afcb50504b190079be59 ---- /dev/null -+++ b/G7-EXTRACTION-PROVENANCE.md -@@ -0,0 +1,10 @@ -+# G7 extraction provenance -+ -+- Destination baseline: `Icod.LineEditor` commit `41fe8ee23b55fd4ad4987bb059754c553cf5aabf`. -+- Source repository: `uniblab/Icod.CoreUtils`. -+- Reviewed source commit: `4ee41aa1dc1c549f85efab6e5fa156a3dfc7271b`. -+- Dependency cut: all eight project references to `Icod.CoreUtils.Shared` are replaced by `Icod.CommandFramework` package version `1.1.0`. -+- Preserved local architecture: `ed` and `red` reference `Icod.LineEditor.Ed.Shared` as a project; `sed` remains separate. -+- Tests and fixtures: `Ed.Shared.Tests`, `Ed.Tests`, `Red.Tests`, and `Sed.Tests` are imported with their fixtures. -+- Historical LineEditor architecture, audit, migration, and Batch 34 notes are retained in `docs/history`. -+- No CoreUtils files are deleted by this bootstrap. -\ No newline at end of file -diff --git a/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj b/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..93cb77ca9fed5d31969d217589c25ecd324c2b52 ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj -@@ -0,0 +1,48 @@ -+ -+ -+ -+ net10.0 -+ 13.0 -+ enable -+ enable -+ true -+ ..\bin\$(Configuration)\ -+ Icod.LineEditor.Ed.Shared -+ Icod.LineEditor.Ed -+ -+ -+ AnyCPU -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/Icod.LineEditor.Ed.Shared/README.md b/Icod.LineEditor.Ed.Shared/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..bfe5f1d27760eabd3c43c6a4bab7574124d47cfd ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/README.md -@@ -0,0 +1,60 @@ -+# Icod.LineEditor.Ed.Shared -+ -+`Icod.LineEditor.Ed.Shared` is the reusable mutable editor engine shared by the `ed` and `red` command projects. -+ -+The project owns Ed-family behavior rather than process-level command-line policy. It provides: -+ -+- segmented mutable line storage with stable line identities; -+- current and last address state, marks, a cut buffer, and one-level reversible undo; -+- Ed address and range parsing independent from Sed's streaming address model; -+- append, insert, change, delete, print, list, number, mark, move, copy, join, yank, put, substitution, global, file, shell, undo, and quit operations; -+- Shared GNU Basic Regular Expression consumption for searches and substitutions; -+- injected file and process capabilities; -+- immutable standard and restricted security profiles; -+- controlled diagnostics, cancellation, signal, and exit-status results. -+ -+The project deliberately has no runtime reference to `Icod.DiffUtils.Shared`. Compatibility with ed scripts emitted by GNU Diffutils and `Icod.DiffUtils` is verified through textual fixtures in `tests/Ed.Shared.Tests`. -+ -+## Dependency direction -+ -+```text -+Icod.CoreUtils.Shared -+ Γåô -+Icod.LineEditor.Ed.Shared -+ Γåô -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+``` -+ -+The `ed` and `red` executable projects consume this engine under the standard and permanently restricted command profiles respectively. -+ -+## Phase LE9 sharing audit -+ -+The completed Ed and Sed engines were compared in Phase LE9. The audit found -+no cohesive residual contract that warrants a general `Icod.LineEditor.Shared` -+assembly. Neutral regular-expression, record, process, temporary, filesystem, -+diagnostic, and text contracts remain in the current Shared incubation -+project. Mutable buffer, address, undo, and Red security behavior remain here; -+Sed program, address/range, cycle, sandbox, and in-place policy remain in -+`Icod.LineEditor.Sed`. -+ -+The evidence and dependency decision are recorded in -+`Icod.LineEditor-LE9-Sharing-Audit.md` and enforced by architecture-boundary -+tests in the Ed.Shared and Sed test projects. -+ -+## Phase LE10 transactional writes -+ -+Complete-file Ed writes and creations now consume Completion Gate E6 through -+`StandardEditorFileAccess`. The capability resolves Ed's terminal-link target, -+freezes an authoritative no-follow identity or absence precondition, stages and -+flushes the complete buffer in a secure sibling file, preserves representable -+mode, ownership, and attributes, and relies on the shared transaction for -+publication, rollback, and cleanup. -+ -+Append remains a direct append-and-flush operation because it is not a -+whole-file replacement. Command-level force, modified-buffer, remembered-name, -+and presentation policy remains in the Ed engine and executable. The previous -+private temporary-name, move, and cleanup algorithm has been removed. -+ -+The complete Sed/Ed integration and test matrix is recorded in -+`Icod.LineEditor-LE10-Transactional-Replacement.md`. -diff --git a/Icod.LineEditor.Ed.Shared/src/EditorAddressParser.cs b/Icod.LineEditor.Ed.Shared/src/EditorAddressParser.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..3068b1753cac4d45f4b64d00fcfba3f84b2eee69 ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/src/EditorAddressParser.cs -@@ -0,0 +1,197 @@ -+namespace Icod.LineEditor.Ed; -+ -+/// Parses Ed addresses and ranges independently from the Sed address model. -+internal sealed class EditorAddressParser { -+ private readonly string text; -+ private readonly Func markResolver; -+ private readonly Func searchResolver; -+ private readonly int lastAddress; -+ private int currentAddress; -+ private int index; -+ -+ /// Initializes an address parser. -+ internal EditorAddressParser( -+ string text, -+ int currentAddress, -+ int lastAddress, -+ Func markResolver, -+ Func searchResolver -+ ) { -+ ArgumentNullException.ThrowIfNull( text ); -+ ArgumentNullException.ThrowIfNull( markResolver ); -+ ArgumentNullException.ThrowIfNull( searchResolver ); -+ this.text = text; -+ this.currentAddress = currentAddress; -+ this.lastAddress = lastAddress; -+ this.markResolver = markResolver; -+ this.searchResolver = searchResolver; -+ } -+ -+ /// Gets the first unconsumed character index. -+ internal int Position => this.index; -+ -+ /// Parses an optional address range. -+ /// The parsed range and whether any address was supplied. -+ internal ParsedEditorRange ParseRange() { -+ this.SkipSpaces(); -+ if ( this.TryConsume( '%' ) ) { -+ return new ParsedEditorRange( -+ true, -+ new EditorAddressRange( 1, this.lastAddress ) -+ ); -+ } -+ -+ var first = this.ParseAddress(); -+ this.SkipSpaces(); -+ if ( this.TryConsume( ',' ) || this.TryConsume( ';' ) ) { -+ var delimiter = this.text[ this.index - 1 ]; -+ var resolvedFirst = first ?? ( ',' == delimiter ? 1 : this.currentAddress ); -+ if ( ';' == delimiter ) { -+ this.currentAddress = resolvedFirst; -+ } -+ this.SkipSpaces(); -+ var second = this.ParseAddress() ?? this.lastAddress; -+ return new ParsedEditorRange( -+ true, -+ new EditorAddressRange( resolvedFirst, second ) -+ ); -+ } -+ if ( null == first ) { -+ return new ParsedEditorRange( false, default ); -+ } -+ return new ParsedEditorRange( -+ true, -+ new EditorAddressRange( first.Value, first.Value ) -+ ); -+ } -+ -+ private int? ParseAddress() { -+ this.SkipSpaces(); -+ if ( this.text.Length <= this.index ) { -+ return null; -+ } -+ int? value = null; -+ var current = this.text[ this.index ]; -+ if ( char.IsAsciiDigit( current ) ) { -+ value = this.ReadNumber(); -+ } else if ( '.' == current ) { -+ this.index++; -+ value = this.currentAddress; -+ } else if ( '$' == current ) { -+ this.index++; -+ value = this.lastAddress; -+ } else if ( '\'' == current ) { -+ this.index++; -+ if ( this.text.Length <= this.index ) { -+ throw new EditorParseException( "Missing mark name." ); -+ } -+ value = this.markResolver( this.text[ this.index++ ] ); -+ } else if ( ( '/' == current ) || ( '?' == current ) ) { -+ this.index++; -+ var pattern = this.ReadDelimited( current ); -+ value = this.searchResolver( pattern, '?' == current, this.currentAddress ); -+ } else if ( ( '+' == current ) || ( '-' == current ) || ( '^' == current ) ) { -+ value = this.currentAddress; -+ } else { -+ return null; -+ } -+ -+ while ( true ) { -+ this.SkipSpaces(); -+ if ( this.text.Length <= this.index ) { -+ break; -+ } -+ var sign = this.text[ this.index ]; -+ if ( ( '+' != sign ) && ( '-' != sign ) && ( '^' != sign ) ) { -+ break; -+ } -+ this.index++; -+ this.SkipSpaces(); -+ var amount = 1; -+ if ( ( this.text.Length > this.index ) && char.IsAsciiDigit( this.text[ this.index ] ) ) { -+ amount = this.ReadNumber(); -+ } -+ try { -+ value = checked( value.Value + ( '+' == sign ? amount : -amount ) ); -+ } catch ( OverflowException ) { -+ throw new EditorParseException( "The address is outside the supported range." ); -+ } -+ } -+ return value; -+ } -+ -+ private int ReadNumber() { -+ var start = this.index; -+ while ( ( this.text.Length > this.index ) && char.IsAsciiDigit( this.text[ this.index ] ) ) { -+ this.index++; -+ } -+ if ( !int.TryParse( -+ this.text.AsSpan( start, this.index - start ), -+ System.Globalization.NumberStyles.None, -+ System.Globalization.CultureInfo.InvariantCulture, -+ out var value -+ ) ) { -+ throw new EditorParseException( "The address is outside the supported range." ); -+ } -+ return value; -+ } -+ -+ private string ReadDelimited( -+ char delimiter -+ ) { -+ var result = new System.Text.StringBuilder(); -+ var escaped = false; -+ while ( this.text.Length > this.index ) { -+ var character = this.text[ this.index++ ]; -+ if ( escaped ) { -+ result.Append( '\\' ); -+ result.Append( character ); -+ escaped = false; -+ continue; -+ } -+ if ( '\\' == character ) { -+ escaped = true; -+ continue; -+ } -+ if ( delimiter == character ) { -+ return result.ToString(); -+ } -+ result.Append( character ); -+ } -+ throw new EditorParseException( "Unterminated regular expression." ); -+ } -+ -+ private void SkipSpaces() { -+ while ( ( this.text.Length > this.index ) && char.IsWhiteSpace( this.text[ this.index ] ) ) { -+ this.index++; -+ } -+ } -+ -+ private bool TryConsume( -+ char character -+ ) { -+ if ( ( this.text.Length <= this.index ) || ( character != this.text[ this.index ] ) ) { -+ return false; -+ } -+ this.index++; -+ return true; -+ } -+} -+ -+/// Represents an optional parsed editor range. -+/// Whether the command supplied an address. -+/// The parsed inclusive range. -+internal readonly record struct ParsedEditorRange( -+ bool IsSpecified, -+ EditorAddressRange Range -+); -+ -+/// Represents a controlled editor command-parse failure. -+internal sealed class EditorParseException : Exception { -+ /// Initializes a parse exception. -+ /// The controlled parse diagnostic. -+ internal EditorParseException( -+ string message -+ ) : base( message ) { -+ } -+} -diff --git a/Icod.LineEditor.Ed.Shared/src/EditorBuffer.cs b/Icod.LineEditor.Ed.Shared/src/EditorBuffer.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..f1d96bea0f598da05dce1e490f9bfdaebe2736d3 ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/src/EditorBuffer.cs -@@ -0,0 +1,338 @@ -+namespace Icod.LineEditor.Ed; -+ -+/// -+/// Stores mutable editor lines in bounded segments while preserving stable line identities. -+/// -+public sealed class EditorBuffer { -+ private const int MaximumSegmentSize = 256; -+ private const int MinimumSegmentSize = MaximumSegmentSize / 4; -+ private readonly List> segments = new(); -+ private long nextLineId = 1; -+ private int count; -+ -+ /// Gets the number of lines in the buffer. -+ public int Count => this.count; -+ -+ /// Gets the line at a one-based address. -+ /// The one-based line address. -+ /// The addressed line. -+ public EditorLine GetLine( -+ int address -+ ) { -+ var location = this.LocateExisting( address ); -+ return this.segments[ location.Segment ][ location.Offset ]; -+ } -+ -+ /// Gets a stable snapshot of all lines in address order. -+ /// The current lines. -+ public IReadOnlyList GetLines() { -+ var result = new List( this.count ); -+ foreach ( var segment in this.segments ) { -+ result.AddRange( segment ); -+ } -+ return result.AsReadOnly(); -+ } -+ -+ /// Finds the current one-based address for a stable line identity. -+ /// The stable line identity. -+ /// The address, or zero when the line no longer exists. -+ public int FindAddress( -+ long lineId -+ ) { -+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero( lineId ); -+ var address = 1; -+ foreach ( var segment in this.segments ) { -+ foreach ( var line in segment ) { -+ if ( lineId == line.Id ) { -+ return address; -+ } -+ address++; -+ } -+ } -+ return 0; -+ } -+ -+ /// Replaces one line's content while retaining its stable identity. -+ /// The one-based line address. -+ /// The replacement content. -+ public void SetContent( -+ int address, -+ ReadOnlyMemory content -+ ) { -+ var location = this.LocateExisting( address ); -+ var existing = this.segments[ location.Segment ][ location.Offset ]; -+ this.segments[ location.Segment ][ location.Offset ] = new EditorLine( -+ existing.Id, -+ content -+ ); -+ } -+ -+ /// Appends lines to the end of the buffer. -+ /// The line contents. -+ /// The inclusive range occupied by the inserted lines, or an empty zero range. -+ public EditorAddressRange Append( -+ IEnumerable> lines -+ ) => this.InsertAfter( this.count, lines ); -+ -+ /// Inserts lines after a zero-based insertion address. -+ /// Zero inserts before the first line; otherwise insertion follows the addressed line. -+ /// The line contents. -+ /// The inclusive range occupied by the inserted lines, or an empty zero range. -+ public EditorAddressRange InsertAfter( -+ int address, -+ IEnumerable> lines -+ ) { -+ ArgumentNullException.ThrowIfNull( lines ); -+ if ( ( 0 > address ) || ( this.count < address ) ) { -+ throw new ArgumentOutOfRangeException( nameof( address ) ); -+ } -+ var inserted = lines.Select( this.CreateLine ).ToList(); -+ var insertedCount = inserted.Count; -+ if ( 0 == insertedCount ) { -+ return new EditorAddressRange( 0, -1 ); -+ } -+ var start = checked( address + 1 ); -+ if ( 0 == this.segments.Count ) { -+ this.segments.Add( inserted ); -+ this.SplitOversizedSegments(); -+ } else if ( 0 == address ) { -+ this.segments[ 0 ].InsertRange( 0, inserted ); -+ this.SplitOversizedSegments(); -+ } else { -+ var location = this.LocateExisting( address ); -+ this.segments[ location.Segment ].InsertRange( location.Offset + 1, inserted ); -+ this.SplitOversizedSegments(); -+ } -+ this.count = checked( this.count + insertedCount ); -+ return new EditorAddressRange( start, checked( start + insertedCount - 1 ) ); -+ } -+ -+ /// Deletes an inclusive address range. -+ /// The range to remove. -+ /// The deleted stable lines. -+ public IReadOnlyList Delete( -+ EditorAddressRange range -+ ) { -+ this.ValidateRange( range ); -+ var deleted = new List( range.Count ); -+ for ( var index = 0; range.Count > index; index++ ) { -+ var location = this.LocateExisting( range.Start ); -+ var segment = this.segments[ location.Segment ]; -+ deleted.Add( segment[ location.Offset ] ); -+ segment.RemoveAt( location.Offset ); -+ this.count--; -+ if ( 0 == segment.Count ) { -+ this.segments.RemoveAt( location.Segment ); -+ } -+ } -+ this.MergeSmallSegments(); -+ return deleted.AsReadOnly(); -+ } -+ -+ /// Replaces an inclusive range with new lines. -+ /// The range to replace. -+ /// The replacement content. -+ /// The new inclusive range. -+ public EditorAddressRange Replace( -+ EditorAddressRange range, -+ IEnumerable> lines -+ ) { -+ this.ValidateRange( range ); -+ var insertionAddress = range.Start - 1; -+ this.Delete( range ); -+ return this.InsertAfter( insertionAddress, lines ); -+ } -+ -+ /// Moves an inclusive range after a destination address while retaining line identities. -+ /// The source range. -+ /// The destination address in the pre-move buffer; zero means before the first line. -+ /// The new range occupied by the moved lines. -+ public EditorAddressRange Move( -+ EditorAddressRange range, -+ int destination -+ ) { -+ this.ValidateRange( range ); -+ if ( ( 0 > destination ) || ( this.count < destination ) ) { -+ throw new ArgumentOutOfRangeException( nameof( destination ) ); -+ } -+ if ( ( range.Start <= destination ) && ( range.End >= destination ) ) { -+ throw new ArgumentException( "The destination is inside the moved range.", nameof( destination ) ); -+ } -+ var moved = this.Delete( range ).ToList(); -+ if ( destination > range.End ) { -+ destination -= range.Count; -+ } -+ return this.InsertExistingAfter( destination, moved ); -+ } -+ -+ /// Copies an inclusive range after a destination using new stable identities. -+ /// The source range. -+ /// The destination address; zero means before the first line. -+ /// The new range occupied by the copies. -+ public EditorAddressRange Copy( -+ EditorAddressRange range, -+ int destination -+ ) { -+ this.ValidateRange( range ); -+ if ( ( 0 > destination ) || ( this.count < destination ) ) { -+ throw new ArgumentOutOfRangeException( nameof( destination ) ); -+ } -+ var content = new List>( range.Count ); -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ content.Add( this.GetLine( address ).Content ); -+ } -+ return this.InsertAfter( destination, content ); -+ } -+ -+ /// Joins an inclusive range using no separator. -+ /// The range to join. -+ /// The address of the joined line. -+ public int Join( -+ EditorAddressRange range -+ ) { -+ this.ValidateRange( range ); -+ var length = 0; -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ length = checked( length + this.GetLine( address ).Content.Length ); -+ } -+ var content = new byte[ length ]; -+ var offset = 0; -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ var line = this.GetLine( address ).Content; -+ line.Span.CopyTo( content.AsSpan( offset ) ); -+ offset += line.Length; -+ } -+ this.SetContent( range.Start, content ); -+ if ( range.End > range.Start ) { -+ this.Delete( new EditorAddressRange( range.Start + 1, range.End ) ); -+ } -+ return range.Start; -+ } -+ -+ /// Replaces all buffer content and resets generated identities. -+ /// The new line content. -+ public void Reset( -+ IEnumerable> lines -+ ) { -+ ArgumentNullException.ThrowIfNull( lines ); -+ this.segments.Clear(); -+ this.count = 0; -+ this.nextLineId = 1; -+ this.Append( lines ); -+ } -+ -+ /// Captures line identities, content, and the next generated identity for one undo unit. -+ /// The immutable buffer snapshot. -+ internal BufferSnapshot CaptureSnapshot() => new( -+ this.GetLines().ToArray(), -+ this.nextLineId -+ ); -+ -+ /// Restores a previously captured buffer snapshot. -+ /// The snapshot to restore. -+ internal void RestoreSnapshot( -+ BufferSnapshot snapshot -+ ) { -+ ArgumentNullException.ThrowIfNull( snapshot ); -+ this.segments.Clear(); -+ this.count = 0; -+ this.nextLineId = snapshot.NextLineId; -+ this.InsertExistingAfter( 0, snapshot.Lines ); -+ } -+ -+ private EditorLine CreateLine( -+ ReadOnlyMemory content -+ ) => new( this.nextLineId++, content ); -+ -+ private EditorAddressRange InsertExistingAfter( -+ int address, -+ IReadOnlyList lines -+ ) { -+ if ( 0 == lines.Count ) { -+ return new EditorAddressRange( 0, -1 ); -+ } -+ var start = checked( address + 1 ); -+ if ( 0 == this.segments.Count ) { -+ this.segments.Add( lines.ToList() ); -+ } else if ( 0 == address ) { -+ this.segments[ 0 ].InsertRange( 0, lines ); -+ } else { -+ var location = this.LocateExisting( address ); -+ this.segments[ location.Segment ].InsertRange( location.Offset + 1, lines ); -+ } -+ this.count = checked( this.count + lines.Count ); -+ this.SplitOversizedSegments(); -+ return new EditorAddressRange( start, checked( start + lines.Count - 1 ) ); -+ } -+ -+ private ( int Segment, int Offset ) LocateExisting( -+ int address -+ ) { -+ if ( ( 1 > address ) || ( this.count < address ) ) { -+ throw new ArgumentOutOfRangeException( nameof( address ) ); -+ } -+ var remaining = address - 1; -+ for ( var segmentIndex = 0; this.segments.Count > segmentIndex; segmentIndex++ ) { -+ var segment = this.segments[ segmentIndex ]; -+ if ( segment.Count > remaining ) { -+ return ( segmentIndex, remaining ); -+ } -+ remaining -= segment.Count; -+ } -+ throw new InvalidOperationException( "The buffer segment index is inconsistent." ); -+ } -+ -+ private void ValidateRange( -+ EditorAddressRange range -+ ) { -+ if ( -+ ( 1 > range.Start ) -+ || ( range.Start > range.End ) -+ || ( this.count < range.End ) -+ ) { -+ throw new ArgumentOutOfRangeException( nameof( range ) ); -+ } -+ } -+ -+ private void SplitOversizedSegments() { -+ for ( var index = 0; this.segments.Count > index; index++ ) { -+ var segment = this.segments[ index ]; -+ if ( MaximumSegmentSize >= segment.Count ) { -+ continue; -+ } -+ var tail = segment.GetRange( -+ MaximumSegmentSize, -+ segment.Count - MaximumSegmentSize -+ ); -+ segment.RemoveRange( -+ MaximumSegmentSize, -+ segment.Count - MaximumSegmentSize -+ ); -+ this.segments.Insert( index + 1, tail ); -+ } -+ } -+ -+ private void MergeSmallSegments() { -+ for ( var index = 0; this.segments.Count - 1 > index; ) { -+ var current = this.segments[ index ]; -+ var next = this.segments[ index + 1 ]; -+ if ( -+ ( MinimumSegmentSize > current.Count ) -+ && ( MaximumSegmentSize >= current.Count + next.Count ) -+ ) { -+ current.AddRange( next ); -+ this.segments.RemoveAt( index + 1 ); -+ } else { -+ index++; -+ } -+ } -+ } -+} -+ -+/// Represents an internal buffer undo snapshot. -+/// The stable lines in address order. -+/// The next identity to allocate. -+internal sealed record BufferSnapshot( -+ IReadOnlyList Lines, -+ long NextLineId -+); -diff --git a/Icod.LineEditor.Ed.Shared/src/EditorCapabilities.cs b/Icod.LineEditor.Ed.Shared/src/EditorCapabilities.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..4408a4ff82098fd8844deca9be82ce33a1d867c9 ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/src/EditorCapabilities.cs -@@ -0,0 +1,612 @@ -+namespace Icod.LineEditor.Ed; -+ -+using System.Text; -+using Icod.CommandFramework.FileSystem; -+using Icod.CommandFramework.FileSystem.Metadata; -+using Icod.CommandFramework.FileSystem.Mutation; -+using Icod.CommandFramework.FileSystem.RecursiveMutation; -+using Icod.CommandFramework.FileSystem.TransactionalReplacement; -+using Icod.CommandFramework.FileSystem.Traversal; -+using Icod.CommandFramework.Records; -+using Icod.CommandFramework.Processes; -+using Icod.CommandFramework.Temporary; -+ -+/// Defines immutable parser and capability policy for an Ed engine instance. -+public sealed record EditorSecurityPolicy { -+ /// Gets the unrestricted standard editor policy. -+ public static EditorSecurityPolicy Standard { get; } = new( -+ isRestricted: false, -+ allowShellCommands: true, -+ allowPathnames: true, -+ allowRememberedFileName: true, -+ workingDirectory: null -+ ); -+ -+ /// Creates a restricted editor policy rooted at one captured working directory. -+ /// The captured working directory. -+ /// The immutable restricted policy. -+ public static EditorSecurityPolicy Restricted( -+ string workingDirectory -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( workingDirectory ); -+ return new EditorSecurityPolicy( -+ isRestricted: true, -+ allowShellCommands: false, -+ allowPathnames: false, -+ allowRememberedFileName: true, -+ workingDirectory: System.IO.Path.GetFullPath( workingDirectory ) -+ ); -+ } -+ -+ /// Initializes an editor security policy. -+ /// Whether restricted-mode parsing and dispatch are enabled. -+ /// Whether shell-bearing commands may be dispatched. -+ /// Whether arbitrary pathnames may be supplied. -+ /// Whether commands may establish a remembered filename. -+ /// The captured restricted directory, when applicable. -+ public EditorSecurityPolicy( -+ bool isRestricted, -+ bool allowShellCommands, -+ bool allowPathnames, -+ bool allowRememberedFileName, -+ string? workingDirectory -+ ) { -+ if ( isRestricted && string.IsNullOrWhiteSpace( workingDirectory ) ) { -+ throw new ArgumentException( -+ "A restricted policy requires a captured working directory.", -+ nameof( workingDirectory ) -+ ); -+ } -+ this.IsRestricted = isRestricted; -+ this.AllowShellCommands = allowShellCommands; -+ this.AllowPathnames = allowPathnames; -+ this.AllowRememberedFileName = allowRememberedFileName; -+ this.WorkingDirectory = workingDirectory; -+ } -+ -+ /// Gets whether restricted-mode parsing and dispatch are enabled. -+ public bool IsRestricted { -+ get; -+ } -+ -+ /// Gets whether shell-bearing commands may be dispatched. -+ public bool AllowShellCommands { -+ get; -+ } -+ -+ /// Gets whether arbitrary pathnames may be supplied. -+ public bool AllowPathnames { -+ get; -+ } -+ -+ /// Gets whether commands may establish a remembered filename. -+ public bool AllowRememberedFileName { -+ get; -+ } -+ -+ /// Gets the captured restricted working directory. -+ public string? WorkingDirectory { -+ get; -+ } -+} -+ -+/// Bundles the immutable editor policy with the only file and process capabilities available to an engine. -+public sealed class EditorCapabilityProfile { -+ /// Creates the standard unrestricted capability profile. -+ /// The file capability. -+ /// The process capability. -+ /// The immutable standard profile. -+ public static EditorCapabilityProfile Standard( -+ IEditorFileAccess fileAccess, -+ IEditorProcessAccess processAccess -+ ) { -+ ArgumentNullException.ThrowIfNull( fileAccess ); -+ ArgumentNullException.ThrowIfNull( processAccess ); -+ return new EditorCapabilityProfile( -+ EditorSecurityPolicy.Standard, -+ fileAccess, -+ processAccess -+ ); -+ } -+ -+ /// Creates the shared restricted profile used by both red and ed --restricted. -+ /// The working directory captured once when the profile is constructed. -+ /// The underlying file capability. -+ /// The immutable restricted profile. -+ public static EditorCapabilityProfile Restricted( -+ string workingDirectory, -+ IEditorFileAccess fileAccess -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( workingDirectory ); -+ ArgumentNullException.ThrowIfNull( fileAccess ); -+ var capturedDirectory = System.IO.Path.GetFullPath( workingDirectory ); -+ return new EditorCapabilityProfile( -+ EditorSecurityPolicy.Restricted( capturedDirectory ), -+ new RestrictedEditorFileAccess( capturedDirectory, fileAccess ), -+ new DeniedEditorProcessAccess() -+ ); -+ } -+ -+ private EditorCapabilityProfile( -+ EditorSecurityPolicy securityPolicy, -+ IEditorFileAccess fileAccess, -+ IEditorProcessAccess processAccess -+ ) { -+ ArgumentNullException.ThrowIfNull( securityPolicy ); -+ ArgumentNullException.ThrowIfNull( fileAccess ); -+ ArgumentNullException.ThrowIfNull( processAccess ); -+ this.SecurityPolicy = securityPolicy; -+ this.FileAccess = fileAccess; -+ this.ProcessAccess = processAccess; -+ } -+ -+ /// Gets the immutable parser and dispatcher policy. -+ public EditorSecurityPolicy SecurityPolicy { get; } -+ -+ /// Gets the file capability exposed to the engine. -+ public IEditorFileAccess FileAccess { get; } -+ -+ /// Gets the process capability exposed to the engine. -+ public IEditorProcessAccess ProcessAccess { get; } -+} -+ -+/// Supplies all filename-bearing effects used by the editor engine. -+public interface IEditorFileAccess { -+ /// Reads LF-delimited records from a file. -+ /// The requested pathname. -+ /// A cancellation token. -+ /// The file records and byte count. -+ ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ); -+ -+ /// Writes LF-delimited records to a file. -+ /// The requested pathname. -+ /// The line content without separators. -+ /// Whether output is appended. -+ /// Whether a final LF is written. -+ /// A cancellation token. -+ /// The number of bytes written. -+ ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ); -+} -+ -+/// Supplies all child-process effects used by the editor engine. -+public interface IEditorProcessAccess { -+ /// Runs a command through the host command interpreter. -+ /// The command text. -+ /// The optional standard-input bytes. -+ /// A cancellation token. -+ /// The process result. -+ ValueTask RunShellAsync( -+ string command, -+ ReadOnlyMemory standardInput, -+ CancellationToken cancellationToken = default -+ ); -+} -+ -+/// -+/// Implements standard file access with Shared record reading, secure sibling staging, and durable flush operations. -+/// -+public sealed class StandardEditorFileAccess : IEditorFileAccess { -+ private const RecursiveMetadataFields ReplacementMetadata = -+ RecursiveMetadataFields.Mode -+ | RecursiveMetadataFields.Ownership -+ | RecursiveMetadataFields.Attributes; -+ -+ private readonly ITransactionalReplacementFileSystem transactionalFileSystem; -+ private readonly IFileSystemOperations fileSystemOperations; -+ private readonly ITransactionalReplacementFailureInjector failureInjector; -+ -+ /// Initializes the system-backed standard file capability. -+ public StandardEditorFileAccess() : this( -+ SystemTransactionalReplacementFileSystem.Instance, -+ SystemFileSystemOperations.Instance, -+ NullTransactionalReplacementFailureInjector.Instance -+ ) { -+ } -+ -+ /// Initializes an injectable standard file capability. -+ /// The secure temporary-object creator. -+ /// The durability operations provider. -+ public StandardEditorFileAccess( -+ SecureTemporaryObjectCreator temporaryObjectCreator, -+ IFileSystemOperations fileSystemOperations -+ ) : this( -+ new SystemTransactionalReplacementFileSystem( -+ SystemFileSystemMetadataProvider.Instance, -+ SystemFileSystemMutationProvider.Instance, -+ fileSystemOperations, -+ temporaryObjectCreator -+ ), -+ fileSystemOperations, -+ NullTransactionalReplacementFailureInjector.Instance -+ ) { -+ } -+ -+ /// Initializes an editor file capability over an injectable E6 transaction provider. -+ /// The shared transactional-replacement filesystem. -+ /// The durability operations provider used by append writes. -+ /// An optional deterministic E6 failure injector. -+ public StandardEditorFileAccess( -+ ITransactionalReplacementFileSystem transactionalFileSystem, -+ IFileSystemOperations fileSystemOperations, -+ ITransactionalReplacementFailureInjector? failureInjector = null -+ ) { -+ ArgumentNullException.ThrowIfNull( transactionalFileSystem ); -+ ArgumentNullException.ThrowIfNull( fileSystemOperations ); -+ this.transactionalFileSystem = transactionalFileSystem; -+ this.fileSystemOperations = fileSystemOperations; -+ this.failureInjector = failureInjector -+ ?? NullTransactionalReplacementFailureInjector.Instance; -+ } -+ -+ /// -+ public async ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( path ); -+ await using var stream = new FileStream( -+ path, -+ FileMode.Open, -+ FileAccess.Read, -+ FileShare.Read, -+ 65536, -+ FileOptions.Asynchronous | FileOptions.SequentialScan -+ ); -+ using var reader = new ByteRecordReader( stream ); -+ var lines = new List>(); -+ var finalTerminated = true; -+ while ( true ) { -+ var record = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == record ) { -+ break; -+ } -+ lines.Add( record.Content.ToArray() ); -+ finalTerminated = record.IsTerminated; -+ } -+ return new EditorFileReadResult( -+ lines.AsReadOnly(), -+ 0 == lines.Count || finalTerminated, -+ stream.Length -+ ); -+ } -+ -+ /// -+ public async ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( path ); -+ ArgumentNullException.ThrowIfNull( lines ); -+ if ( append ) { -+ await using var appendStream = new FileStream( -+ path, -+ FileMode.Append, -+ FileAccess.Write, -+ FileShare.Read, -+ 65536, -+ FileOptions.Asynchronous -+ ); -+ var appended = await WriteRecordsAsync( -+ appendStream, -+ lines, -+ terminateFinalRecord, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await appendStream.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ await this.fileSystemOperations.FlushFileAsync( -+ appendStream, -+ FileFlushMode.DataAndMetadata, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new EditorFileWriteResult( appended ); -+ } -+ -+ var fullPath = ResolveReplacementPath( path ); -+ var observation = await this.transactionalFileSystem.ObserveAsync( -+ fullPath, -+ PathDereferenceMode.NoFollow, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ var precondition = CreatePrecondition( observation ); -+ var metadata = observation.Metadata; -+ var metadataPlan = null == metadata -+ ? null -+ : RecursiveMetadataPreservationPlan.Create( -+ metadata, -+ ReplacementMetadata, -+ RecursiveMetadataFields.None -+ ); -+ long written = 0; -+ var artifact = new TransactionalReplacementArtifact( -+ recoveryUnitId: "ed-write", -+ path: fullPath, -+ action: TransactionalReplacementAction.Replace, -+ precondition: precondition, -+ contentWriter: async ( destination, token ) => { -+ written = await WriteRecordsAsync( -+ destination, -+ lines, -+ terminateFinalRecord, -+ token -+ ).ConfigureAwait( false ); -+ }, -+ displayName: path, -+ sourceMetadata: metadata, -+ metadataPlan: metadataPlan -+ ); -+ await using var transaction = new TransactionalFileReplacementTransaction( -+ new TransactionalReplacementArtifact[] { artifact }, -+ this.transactionalFileSystem, -+ TransactionalReplacementOptions.Default, -+ failureInjector: this.failureInjector -+ ); -+ var transactionResult = await transaction.CommitAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( !transactionResult.Succeeded ) { -+ throw CreateTransactionException( "ed write", transactionResult ); -+ } -+ return new EditorFileWriteResult( written ); -+ } -+ -+ private static string ResolveReplacementPath( -+ string path -+ ) { -+ var fullPath = System.IO.Path.GetFullPath( path ); -+ var information = new FileInfo( fullPath ); -+ if ( string.IsNullOrEmpty( information.LinkTarget ) ) { -+ return fullPath; -+ } -+ var target = information.ResolveLinkTarget( returnFinalTarget: true ); -+ return target?.FullName -+ ?? throw new IOException( "The editor write target could not be resolved." ); -+ } -+ -+ private static FileSystemMutationPrecondition CreatePrecondition( -+ TransactionalReplacementObservation observation -+ ) { -+ if ( !observation.Exists ) { -+ return FileSystemMutationPrecondition.DestinationMustNotExist(); -+ } -+ var metadata = observation.Metadata -+ ?? throw new IOException( "The destination metadata is unavailable." ); -+ return FileSystemMutationPrecondition.FromObservation( -+ metadata.Kind, -+ metadata.EntryIdentity, -+ PathDereferenceMode.NoFollow -+ ); -+ } -+ -+ private static IOException CreateTransactionException( -+ string operation, -+ TransactionalReplacementResult result -+ ) { -+ var diagnostic = 0 == result.Diagnostics.Count -+ ? null -+ : result.Diagnostics[ result.Diagnostics.Count - 1 ]; -+ return new IOException( -+ null == diagnostic -+ ? $"{operation} failed with outcome {result.Outcome}." -+ : diagnostic.Message, -+ diagnostic?.Exception -+ ); -+ } -+ -+ private static async ValueTask WriteRecordsAsync( -+ Stream stream, -+ IReadOnlyList> lines, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken -+ ) { -+ long written = 0; -+ for ( var index = 0; lines.Count > index; index++ ) { -+ var line = lines[ index ]; -+ await stream.WriteAsync( line, cancellationToken ).ConfigureAwait( false ); -+ written = checked( written + line.Length ); -+ if ( terminateFinalRecord || lines.Count - 1 > index ) { -+ await stream.WriteAsync( -+ new ReadOnlyMemory( new byte[] { (byte)'\n' } ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ written++; -+ } -+ } -+ return written; -+ } -+} -+ -+/// Implements shell execution through Shared . -+public sealed class StandardEditorProcessAccess : IEditorProcessAccess { -+ /// -+ public async ValueTask RunShellAsync( -+ string command, -+ ReadOnlyMemory standardInput, -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( command ); -+ await using var input = new MemoryStream( standardInput.ToArray(), writable: false ); -+ var options = new ProcessRunOptions( -+ OperatingSystem.IsWindows() -+ ? Environment.GetEnvironmentVariable( "COMSPEC" ) ?? "cmd.exe" -+ : "/bin/sh" -+ ) { -+ CaptureStandardOutput = true, -+ CaptureStandardError = true, -+ OutputEncoding = Encoding.UTF8, -+ StandardInput = input -+ }; -+ if ( OperatingSystem.IsWindows() ) { -+ options.Arguments.Add( "/d" ); -+ options.Arguments.Add( "/s" ); -+ options.Arguments.Add( "/c" ); -+ } else { -+ options.Arguments.Add( "-c" ); -+ } -+ options.Arguments.Add( command ); -+ var result = await ProcessRunner.RunAsync( -+ options, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new EditorProcessResult( -+ result.ExitCode, -+ result.WasCanceled, -+ Encoding.UTF8.GetBytes( result.StandardOutput ?? string.Empty ), -+ Encoding.UTF8.GetBytes( result.StandardError ?? string.Empty ) -+ ); -+ } -+} -+ -+/// Rejects every file operation without touching the host filesystem. -+public sealed class DeniedEditorFileAccess : IEditorFileAccess { -+ /// -+ public ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) => ValueTask.FromException( -+ new UnauthorizedAccessException( "File access is denied by the editor security profile." ) -+ ); -+ -+ /// -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) => ValueTask.FromException( -+ new UnauthorizedAccessException( "File access is denied by the editor security profile." ) -+ ); -+} -+ -+/// Rejects every process operation without starting a child process. -+public sealed class DeniedEditorProcessAccess : IEditorProcessAccess { -+ /// -+ public ValueTask RunShellAsync( -+ string command, -+ ReadOnlyMemory standardInput, -+ CancellationToken cancellationToken = default -+ ) => ValueTask.FromException( -+ new UnauthorizedAccessException( "Process access is denied by the editor security profile." ) -+ ); -+} -+ -+/// -+/// Restricts file operations to simple leaf names beneath one captured working directory. -+/// This is a pathname policy compatible with GNU restricted ed; it is not physical filesystem confinement. -+/// A permitted leaf may therefore name a hard link, symbolic link, mount point, or reparse point resolved by -+/// the underlying filesystem capability. Avoiding a separate link pre-check also avoids introducing a -+/// check-then-use race that could be mistaken for a security boundary. -+/// -+public sealed class RestrictedEditorFileAccess : IEditorFileAccess { -+ private readonly string workingDirectory; -+ private readonly IEditorFileAccess inner; -+ -+ /// Initializes a restricted pathname capability. -+ /// The working directory captured once for the lifetime of the capability. -+ /// The underlying file capability. -+ public RestrictedEditorFileAccess( -+ string workingDirectory, -+ IEditorFileAccess inner -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( workingDirectory ); -+ ArgumentNullException.ThrowIfNull( inner ); -+ this.workingDirectory = System.IO.Path.GetFullPath( workingDirectory ); -+ this.inner = inner; -+ } -+ -+ /// Gets the working directory captured when this capability was constructed. -+ public string WorkingDirectory => this.workingDirectory; -+ -+ /// Gets whether this capability claims physical confinement. -+ public bool ProvidesPhysicalConfinement => false; -+ -+ /// -+ public ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) => this.inner.ReadAsync( this.Resolve( path ), cancellationToken ); -+ -+ /// -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) => this.inner.WriteAsync( -+ this.Resolve( path ), -+ lines, -+ append, -+ terminateFinalRecord, -+ cancellationToken -+ ); -+ -+ private string Resolve( -+ string path -+ ) { -+ ArgumentException.ThrowIfNullOrWhiteSpace( path ); -+ if ( !EditorRestrictedPath.IsSimpleFileName( path ) ) { -+ throw new UnauthorizedAccessException( -+ "Restricted editor file access permits only a simple filename." -+ ); -+ } -+ var resolved = System.IO.Path.GetFullPath( System.IO.Path.Combine( this.workingDirectory, path ) ); -+ var comparison = OperatingSystem.IsWindows() -+ ? StringComparison.OrdinalIgnoreCase -+ : StringComparison.Ordinal; -+ if ( !string.Equals( System.IO.Path.GetDirectoryName( resolved ), this.workingDirectory, comparison ) ) { -+ throw new UnauthorizedAccessException( -+ "The resolved filename is outside the captured working directory." -+ ); -+ } -+ return resolved; -+ } -+} -+ -+/// Provides host-independent restricted-ed pathname classification. -+public static class EditorRestrictedPath { -+ private static readonly HashSet WindowsDeviceNames = new( -+ StringComparer.OrdinalIgnoreCase -+ ) { -+ "AUX", "CLOCK$", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", -+ "COM7", "COM8", "COM9", "CON", "CONIN$", "CONOUT$", "LPT1", "LPT2", -+ "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "NUL", "PRN" -+ }; -+ -+ /// Returns whether a candidate is a simple filename under both Unix and Windows pathname rules. -+ /// The logical filename. -+ /// only for a non-special leaf name. -+ public static bool IsSimpleFileName( -+ string candidate -+ ) { -+ if ( -+ string.IsNullOrWhiteSpace( candidate ) -+ || System.IO.Path.IsPathRooted( candidate ) -+ || candidate.Contains( '/' ) -+ || candidate.Contains( '\\' ) -+ || candidate.Contains( ':' ) -+ || candidate.StartsWith( '!' ) -+ || candidate.EndsWith( ' ' ) -+ || candidate.EndsWith( '.' ) -+ || "." == candidate -+ || ".." == candidate -+ ) { -+ return false; -+ } -+ var extension = candidate.IndexOf( '.' ); -+ var stem = 0 > extension ? candidate : candidate[ ..extension ]; -+ return !WindowsDeviceNames.Contains( stem ); -+ } -+} -diff --git a/Icod.LineEditor.Ed.Shared/src/EditorEngine.cs b/Icod.LineEditor.Ed.Shared/src/EditorEngine.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..dbbd7d8480d53f7fb51e5a246f78422b1edb83d7 ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/src/EditorEngine.cs -@@ -0,0 +1,1586 @@ -+namespace Icod.LineEditor.Ed; -+ -+using System.Globalization; -+using System.Text; -+using Icod.CommandFramework.Records; -+using Icod.CommandFramework.RegularExpressions; -+ -+/// -+/// Executes Ed scripts over a mutable, stable-identity line buffer using injectable regular-expression, -+/// file, process, and security capabilities. -+/// -+public sealed class EditorEngine { -+ private static readonly ReadOnlyMemory LineFeed = new byte[] { (byte)'\n' }; -+ private readonly IRegularExpressionProvider regularExpressionProvider; -+ private readonly IEditorFileAccess fileAccess; -+ private readonly IEditorProcessAccess processAccess; -+ private readonly Dictionary marks = new(); -+ private readonly List> cutBuffer = new(); -+ private EditorSnapshot? undoSnapshot; -+ private string? lastRegularExpression; -+ private string? lastReplacement; -+ private string? lastShellCommand; -+ private EditorSignal pendingSignal; -+ private bool globalExecutionActive; -+ -+ /// Initializes an engine with the standard security profile and system capabilities. -+ public EditorEngine() : this( -+ EditorSecurityPolicy.Standard, -+ new StandardEditorFileAccess(), -+ new StandardEditorProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ) { -+ } -+ -+ /// Initializes an engine from one immutable capability profile. -+ /// The policy and capabilities exposed to the engine. -+ /// The Shared GNU regular-expression provider. -+ public EditorEngine( -+ EditorCapabilityProfile profile, -+ IRegularExpressionProvider regularExpressionProvider -+ ) : this( -+ profile?.SecurityPolicy ?? throw new ArgumentNullException( nameof( profile ) ), -+ profile?.FileAccess ?? throw new ArgumentNullException( nameof( profile ) ), -+ profile?.ProcessAccess ?? throw new ArgumentNullException( nameof( profile ) ), -+ regularExpressionProvider -+ ) { -+ } -+ -+ /// Initializes an engine with explicit policy and capabilities. -+ /// The immutable parser and dispatch policy. -+ /// The filename-bearing capability. -+ /// The process capability. -+ /// The Shared GNU BRE provider. -+ public EditorEngine( -+ EditorSecurityPolicy securityPolicy, -+ IEditorFileAccess fileAccess, -+ IEditorProcessAccess processAccess, -+ IRegularExpressionProvider regularExpressionProvider -+ ) { -+ ArgumentNullException.ThrowIfNull( securityPolicy ); -+ ArgumentNullException.ThrowIfNull( fileAccess ); -+ ArgumentNullException.ThrowIfNull( processAccess ); -+ ArgumentNullException.ThrowIfNull( regularExpressionProvider ); -+ this.SecurityPolicy = securityPolicy; -+ this.fileAccess = fileAccess; -+ this.processAccess = processAccess; -+ this.regularExpressionProvider = regularExpressionProvider; -+ this.Buffer = new EditorBuffer(); -+ } -+ -+ /// Creates a restricted engine rooted at the supplied working directory. -+ /// The captured working directory. -+ /// The underlying file capability to constrain beneath the captured directory. -+ /// An optional Shared GNU BRE provider. -+ /// The configured engine. -+ public static EditorEngine CreateRestricted( -+ string workingDirectory, -+ IEditorFileAccess fileAccess, -+ IRegularExpressionProvider? regularExpressionProvider = null -+ ) => new( -+ EditorCapabilityProfile.Restricted( workingDirectory, fileAccess ), -+ regularExpressionProvider ?? GnuBasicRegularExpressionProvider.Default -+ ); -+ -+ /// Creates a system-backed restricted engine rooted at the supplied working directory. -+ /// The captured working directory. -+ /// The configured engine. -+ public static EditorEngine CreateRestricted( -+ string workingDirectory -+ ) => CreateRestricted( -+ workingDirectory, -+ new StandardEditorFileAccess() -+ ); -+ -+ /// Gets the mutable line buffer. -+ public EditorBuffer Buffer { -+ get; -+ } -+ -+ /// Gets the immutable security policy. -+ public EditorSecurityPolicy SecurityPolicy { -+ get; -+ } -+ -+ /// Gets the current one-based line address, or zero for an empty buffer. -+ public int CurrentAddress { -+ get; -+ private set; -+ } -+ -+ /// Gets whether the buffer contains changes not cleared by an edit or write command. -+ public bool IsModified { -+ get; -+ private set; -+ } -+ -+ /// Gets the remembered filename, when one is permitted and established. -+ public string? RememberedFileName { -+ get; -+ private set; -+ } -+ -+ /// Gets whether the final buffered record should be terminated when written. -+ public bool FinalRecordTerminated { -+ get; -+ private set; -+ } = true; -+ -+ /// Gets the last controlled diagnostic. -+ public EditorDiagnostic? LastDiagnostic { -+ get; -+ private set; -+ } -+ -+ /// Sets the current address for command-line initial-address selection. -+ /// A line address from zero through the current buffer size. -+ public void SetCurrentAddress( -+ int address -+ ) { -+ if ( ( 0 > address ) || ( this.Buffer.Count < address ) ) { -+ throw new ArgumentOutOfRangeException( -+ nameof( address ), -+ "The current address must identify the empty position or an existing line." -+ ); -+ } -+ this.CurrentAddress = address; -+ } -+ -+ /// Requests cooperative signal handling before the next command transition. -+ /// The requested signal. -+ public void RequestSignal( -+ EditorSignal signal -+ ) { -+ if ( EditorSignal.None == signal ) { -+ throw new ArgumentOutOfRangeException( nameof( signal ) ); -+ } -+ this.pendingSignal = signal; -+ } -+ -+ /// Loads initial records without creating an undo unit. -+ /// The initial line content. -+ /// Whether the final record was terminated. -+ /// The initial remembered filename. -+ public void Load( -+ IEnumerable> lines, -+ bool finalRecordTerminated = true, -+ string? rememberedFileName = null -+ ) { -+ ArgumentNullException.ThrowIfNull( lines ); -+ if ( null != rememberedFileName ) { -+ if ( !this.SecurityPolicy.AllowRememberedFileName ) { -+ throw new UnauthorizedAccessException( -+ "The editor security profile denies remembered filenames." -+ ); -+ } -+ if ( this.SecurityPolicy.IsRestricted && !IsRestrictedFileName( rememberedFileName ) ) { -+ throw new UnauthorizedAccessException( -+ "Restricted mode permits only a simple remembered filename." -+ ); -+ } -+ } -+ this.Buffer.Reset( lines ); -+ this.CurrentAddress = this.Buffer.Count; -+ this.FinalRecordTerminated = finalRecordTerminated; -+ this.RememberedFileName = rememberedFileName; -+ this.IsModified = false; -+ this.marks.Clear(); -+ this.cutBuffer.Clear(); -+ this.undoSnapshot = null; -+ this.LastDiagnostic = null; -+ } -+ -+ /// Executes an LF-delimited Ed command stream. -+ /// The script stream. -+ /// The output destination. -+ /// The diagnostic and shell-error destination. -+ /// The stable script source name. -+ /// A cancellation token. -+ /// The controlled execution result. -+ public async ValueTask ExecuteScriptAsync( -+ Stream script, -+ Stream standardOutput, -+ Stream standardError, -+ string sourceName = "", -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentNullException.ThrowIfNull( script ); -+ ArgumentNullException.ThrowIfNull( standardOutput ); -+ ArgumentNullException.ThrowIfNull( standardError ); -+ ArgumentException.ThrowIfNullOrWhiteSpace( sourceName ); -+ -+ IReadOnlyList> records; -+ try { -+ records = await ReadScriptAsync( script, cancellationToken ).ConfigureAwait( false ); -+ } catch ( OperationCanceledException ) { -+ var signal = EditorSignal.None == this.pendingSignal -+ ? EditorSignal.Interrupt -+ : this.pendingSignal; -+ this.pendingSignal = EditorSignal.None; -+ var diagnostic = new EditorDiagnostic( -+ EditorDiagnosticCode.Interrupted, -+ "Editor execution was interrupted.", -+ sourceName, -+ 1 -+ ); -+ this.LastDiagnostic = diagnostic; -+ return new EditorExecutionResult( -+ EditorExitStatus.Interrupted, -+ diagnostic, -+ false, -+ signal -+ ); -+ } -+ for ( var index = 0; records.Count > index; index++ ) { -+ try { -+ this.ThrowIfInterrupted( cancellationToken ); -+ var lineNumber = checked( (long)index + 1 ); -+ var command = Encoding.UTF8.GetString( records[ index ].Span ); -+ var outcome = await this.ExecuteCommandAsync( -+ command, -+ records, -+ index, -+ standardOutput, -+ standardError, -+ sourceName, -+ lineNumber, -+ captureUndo: true, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ index = outcome.LastConsumedRecord; -+ if ( outcome.QuitRequested ) { -+ await standardOutput.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ await standardError.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ return new EditorExecutionResult( -+ EditorExitStatus.Success, -+ null, -+ true, -+ EditorSignal.None -+ ); -+ } -+ } catch ( OperationCanceledException ) { -+ var signal = EditorSignal.None == this.pendingSignal -+ ? EditorSignal.Interrupt -+ : this.pendingSignal; -+ this.pendingSignal = EditorSignal.None; -+ var diagnostic = new EditorDiagnostic( -+ EditorDiagnosticCode.Interrupted, -+ "Editor execution was interrupted.", -+ sourceName, -+ checked( (long)index + 1 ) -+ ); -+ this.LastDiagnostic = diagnostic; -+ return new EditorExecutionResult( -+ EditorExitStatus.Interrupted, -+ diagnostic, -+ false, -+ signal -+ ); -+ } catch ( EditorCommandException exception ) { -+ var diagnostic = new EditorDiagnostic( -+ exception.Code, -+ exception.Message, -+ sourceName, -+ checked( (long)index + 1 ) -+ ); -+ this.LastDiagnostic = diagnostic; -+ await standardError.WriteAsync( new byte[] { (byte)'?', (byte)'\n' }, cancellationToken ).ConfigureAwait( false ); -+ await standardError.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ return new EditorExecutionResult( -+ EditorExitStatus.Error, -+ diagnostic, -+ false, -+ EditorSignal.None -+ ); -+ } catch ( Exception exception ) when ( -+ exception is IOException -+ or UnauthorizedAccessException -+ or System.ComponentModel.Win32Exception -+ ) { -+ var diagnostic = new EditorDiagnostic( -+ exception is UnauthorizedAccessException -+ ? EditorDiagnosticCode.RestrictedOperation -+ : EditorDiagnosticCode.FileOperation, -+ exception.Message, -+ sourceName, -+ checked( (long)index + 1 ) -+ ); -+ this.LastDiagnostic = diagnostic; -+ await standardError.WriteAsync( new byte[] { (byte)'?', (byte)'\n' }, cancellationToken ).ConfigureAwait( false ); -+ await standardError.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ return new EditorExecutionResult( -+ EditorExitStatus.Error, -+ diagnostic, -+ false, -+ EditorSignal.None -+ ); -+ } -+ } -+ await standardOutput.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ await standardError.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ return new EditorExecutionResult( -+ EditorExitStatus.Success, -+ null, -+ false, -+ EditorSignal.None -+ ); -+ } -+ -+ private async ValueTask ExecuteCommandAsync( -+ string commandText, -+ IReadOnlyList> scriptRecords, -+ int scriptIndex, -+ Stream standardOutput, -+ Stream standardError, -+ string sourceName, -+ long lineNumber, -+ bool captureUndo, -+ CancellationToken cancellationToken -+ ) { -+ this.ThrowIfInterrupted( cancellationToken ); -+ this.ValidateRestrictedCommandText( commandText ); -+ var parser = new EditorAddressParser( -+ commandText, -+ this.CurrentAddress, -+ this.Buffer.Count, -+ this.ResolveMark, -+ ( pattern, reverse, startAddress ) => this.SearchAddress( -+ pattern, -+ reverse, -+ startAddress, -+ cancellationToken -+ ) -+ ); -+ ParsedEditorRange parsedRange; -+ try { -+ parsedRange = parser.ParseRange(); -+ } catch ( EditorParseException exception ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, exception.Message ); -+ } -+ var position = parser.Position; -+ while ( ( commandText.Length > position ) && char.IsWhiteSpace( commandText[ position ] ) ) { -+ position++; -+ } -+ if ( commandText.Length <= position ) { -+ var next = this.CurrentAddress + 1; -+ if ( ( 1 > next ) || ( this.Buffer.Count < next ) ) { -+ throw new EditorCommandException( -+ EditorDiagnosticCode.InvalidAddress, -+ 0 == this.Buffer.Count -+ ? "The buffer is empty." -+ : "There is no next line." -+ ); -+ } -+ await this.PrintRangeAsync( -+ new EditorAddressRange( next, next ), -+ standardOutput, -+ PrintMode.Plain, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ -+ var command = commandText[ position++ ]; -+ var arguments = commandText[ position.. ]; -+ switch ( command ) { -+ case '#': -+ return new CommandOutcome( scriptIndex, false ); -+ case 'a': { -+ var address = this.ResolveSingleAddress( parsedRange, this.CurrentAddress, allowZero: true ); -+ var block = ReadDataBlock( scriptRecords, scriptIndex ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var inserted = this.Buffer.InsertAfter( address, block.Lines ); -+ this.CurrentAddress = 0 == inserted.Start ? address : inserted.End; -+ this.IsModified = 0 < block.Lines.Count || this.IsModified; -+ return new CommandOutcome( block.LastConsumedRecord, false ); -+ } -+ case 'i': { -+ var address = this.ResolveSingleAddress( parsedRange, this.CurrentAddress, allowZero: false ); -+ var block = ReadDataBlock( scriptRecords, scriptIndex ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var inserted = this.Buffer.InsertAfter( Math.Max( 0, address - 1 ), block.Lines ); -+ this.CurrentAddress = 0 == inserted.Start ? address : inserted.End; -+ this.IsModified = 0 < block.Lines.Count || this.IsModified; -+ return new CommandOutcome( block.LastConsumedRecord, false ); -+ } -+ case 'c': { -+ var range = this.ResolveRange( parsedRange, this.CurrentAddress, this.CurrentAddress ); -+ var block = ReadDataBlock( scriptRecords, scriptIndex ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ this.cutBuffer.Clear(); -+ this.cutBuffer.AddRange( this.Buffer.Delete( range ).Select( line => new ReadOnlyMemory( line.Content.ToArray() ) ) ); -+ var inserted = this.Buffer.InsertAfter( range.Start - 1, block.Lines ); -+ this.CurrentAddress = 0 == inserted.Start -+ ? Math.Min( this.Buffer.Count, range.Start - 1 ) -+ : inserted.End; -+ this.RemoveDanglingMarks(); -+ this.IsModified = true; -+ return new CommandOutcome( block.LastConsumedRecord, false ); -+ } -+ case 'd': { -+ var range = this.ResolveRange( parsedRange, this.CurrentAddress, this.CurrentAddress ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ this.cutBuffer.Clear(); -+ this.cutBuffer.AddRange( this.Buffer.Delete( range ).Select( line => new ReadOnlyMemory( line.Content.ToArray() ) ) ); -+ this.CurrentAddress = 0 == this.Buffer.Count -+ ? 0 -+ : Math.Min( range.Start, this.Buffer.Count ); -+ this.RemoveDanglingMarks(); -+ this.IsModified = true; -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'p': -+ case 'n': -+ case 'l': { -+ var range = this.ResolveRange( parsedRange, this.CurrentAddress, this.CurrentAddress ); -+ await this.PrintRangeAsync( -+ range, -+ standardOutput, -+ 'p' == command ? PrintMode.Plain : 'n' == command ? PrintMode.Numbered : PrintMode.List, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case '=': { -+ var address = this.ResolveSingleAddress( parsedRange, this.Buffer.Count, allowZero: true ); -+ await WriteTextLineAsync( -+ standardOutput, -+ address.ToString( CultureInfo.InvariantCulture ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'k': { -+ var address = this.ResolveSingleAddress( parsedRange, this.CurrentAddress, allowZero: false ); -+ var mark = arguments.Trim(); -+ if ( ( 1 != mark.Length ) || ( 'a' > mark[ 0 ] ) || ( 'z' < mark[ 0 ] ) ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "A mark name from a through z is required." ); -+ } -+ this.marks[ mark[ 0 ] ] = this.Buffer.GetLine( address ).Id; -+ this.CurrentAddress = address; -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'm': -+ case 't': { -+ var range = this.ResolveRange( parsedRange, this.CurrentAddress, this.CurrentAddress ); -+ var destination = this.ParseDestinationAddress( arguments, cancellationToken ); -+ if ( -+ ( 'm' == command ) -+ && ( range.Start <= destination ) -+ && ( range.End >= destination ) -+ ) { -+ throw new EditorCommandException( -+ EditorDiagnosticCode.InvalidAddress, -+ "The move destination is inside the addressed range." -+ ); -+ } -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var moved = 'm' == command -+ ? this.Buffer.Move( range, destination ) -+ : this.Buffer.Copy( range, destination ); -+ this.CurrentAddress = moved.End; -+ this.IsModified = true; -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'j': { -+ var defaultStart = Math.Max( 1, this.CurrentAddress ); -+ var defaultEnd = Math.Min( this.Buffer.Count, defaultStart + 1 ); -+ var range = this.ResolveRange( parsedRange, defaultStart, defaultEnd ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ this.CurrentAddress = this.Buffer.Join( range ); -+ this.RemoveDanglingMarks(); -+ this.IsModified = true; -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'y': { -+ var range = this.ResolveRange( parsedRange, this.CurrentAddress, this.CurrentAddress ); -+ this.cutBuffer.Clear(); -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ this.cutBuffer.Add( this.Buffer.GetLine( address ).Content.ToArray() ); -+ } -+ this.CurrentAddress = range.End; -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'x': { -+ var address = this.ResolveSingleAddress( parsedRange, this.CurrentAddress, allowZero: true ); -+ if ( 0 == this.cutBuffer.Count ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "The cut buffer is empty." ); -+ } -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var inserted = this.Buffer.InsertAfter( address, this.cutBuffer ); -+ this.CurrentAddress = inserted.End; -+ this.IsModified = true; -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 's': { -+ var range = this.ResolveRange( parsedRange, this.CurrentAddress, this.CurrentAddress ); -+ var previousUndo = this.undoSnapshot; -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var changed = this.Substitute( range, arguments, cancellationToken ); -+ if ( !changed.IsMatch ) { -+ if ( captureUndo ) { -+ this.undoSnapshot = previousUndo; -+ } -+ throw new EditorCommandException( EditorDiagnosticCode.RegularExpression, "No match." ); -+ } -+ this.CurrentAddress = changed.LastChangedAddress; -+ this.IsModified = true; -+ if ( changed.PrintChanged ) { -+ await this.PrintRangeAsync( -+ new EditorAddressRange( this.CurrentAddress, this.CurrentAddress ), -+ standardOutput, -+ PrintMode.Plain, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'g': -+ case 'v': { -+ var range = this.ResolveRange( parsedRange, 1, this.Buffer.Count ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ await this.ExecuteGlobalAsync( -+ range, -+ arguments, -+ 'v' == command, -+ scriptRecords, -+ scriptIndex, -+ standardOutput, -+ standardError, -+ sourceName, -+ lineNumber, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'u': -+ this.Undo(); -+ return new CommandOutcome( scriptIndex, false ); -+ case 'e': -+ case 'E': { -+ if ( ( 'e' == command ) && this.IsModified ) { -+ throw new EditorCommandException( EditorDiagnosticCode.ModifiedBuffer, "The buffer has unsaved changes." ); -+ } -+ var path = this.ResolveFileName( arguments, requireName: true ); -+ var read = await this.fileAccess.ReadAsync( path, cancellationToken ).ConfigureAwait( false ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ this.Buffer.Reset( read.Lines ); -+ this.CurrentAddress = this.Buffer.Count; -+ this.FinalRecordTerminated = read.FinalRecordTerminated; -+ this.SetRememberedFileName( path ); -+ this.IsModified = false; -+ this.marks.Clear(); -+ this.cutBuffer.Clear(); -+ await WriteTextLineAsync( -+ standardOutput, -+ read.ByteCount.ToString( CultureInfo.InvariantCulture ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'r': { -+ var address = this.ResolveSingleAddress( parsedRange, this.Buffer.Count, allowZero: true ); -+ var path = this.ResolveFileName( arguments, requireName: true ); -+ var read = await this.fileAccess.ReadAsync( path, cancellationToken ).ConfigureAwait( false ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var inserted = this.Buffer.InsertAfter( address, read.Lines ); -+ this.CurrentAddress = 0 == inserted.Start ? address : inserted.End; -+ if ( ( 0 < read.Lines.Count ) && ( this.Buffer.Count == inserted.End ) ) { -+ this.FinalRecordTerminated = read.FinalRecordTerminated; -+ } -+ this.IsModified = 0 < read.Lines.Count || this.IsModified; -+ if ( null == this.RememberedFileName ) { -+ this.SetRememberedFileName( path ); -+ } -+ await WriteTextLineAsync( -+ standardOutput, -+ read.ByteCount.ToString( CultureInfo.InvariantCulture ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'w': -+ case 'W': { -+ var range = this.ResolveRange( parsedRange, 1, this.Buffer.Count ); -+ var path = this.ResolveFileName( arguments, requireName: true ); -+ var lines = new List>( range.Count ); -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ lines.Add( this.Buffer.GetLine( address ).Content ); -+ } -+ var write = await this.fileAccess.WriteAsync( -+ path, -+ lines.AsReadOnly(), -+ 'W' == command, -+ this.FinalRecordTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( null == this.RememberedFileName ) { -+ this.SetRememberedFileName( path ); -+ } -+ if ( ( 1 == range.Start ) && ( this.Buffer.Count == range.End ) && ( 'w' == command ) ) { -+ this.IsModified = false; -+ } -+ await WriteTextLineAsync( -+ standardOutput, -+ write.ByteCount.ToString( CultureInfo.InvariantCulture ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'f': { -+ var candidate = ParseFileNameArgument( arguments ); -+ if ( 0 < candidate.Length ) { -+ this.SetRememberedFileName( candidate ); -+ } -+ if ( null == this.RememberedFileName ) { -+ throw new EditorCommandException( EditorDiagnosticCode.FileName, "No current filename." ); -+ } -+ await WriteTextLineAsync( standardOutput, this.RememberedFileName, cancellationToken ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case '!': { -+ await this.ExecuteShellAsync( -+ parsedRange, -+ arguments, -+ standardOutput, -+ standardError, -+ captureUndo, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new CommandOutcome( scriptIndex, false ); -+ } -+ case 'q': -+ if ( this.IsModified ) { -+ throw new EditorCommandException( EditorDiagnosticCode.ModifiedBuffer, "The buffer has unsaved changes." ); -+ } -+ return new CommandOutcome( scriptIndex, true ); -+ case 'Q': -+ return new CommandOutcome( scriptIndex, true ); -+ case 'h': -+ if ( null != this.LastDiagnostic ) { -+ await WriteTextLineAsync( standardError, this.LastDiagnostic.Message, cancellationToken ).ConfigureAwait( false ); -+ } -+ return new CommandOutcome( scriptIndex, false ); -+ case 'H': -+ return new CommandOutcome( scriptIndex, false ); -+ default: -+ throw new EditorCommandException( -+ EditorDiagnosticCode.InvalidCommand, -+ string.Concat( "Unknown command: ", command ) -+ ); -+ } -+ } -+ -+ private async ValueTask ExecuteGlobalAsync( -+ EditorAddressRange range, -+ string arguments, -+ bool invert, -+ IReadOnlyList> scriptRecords, -+ int scriptIndex, -+ Stream standardOutput, -+ Stream standardError, -+ string sourceName, -+ long lineNumber, -+ CancellationToken cancellationToken -+ ) { -+ if ( this.globalExecutionActive ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "Nested global commands are not permitted." ); -+ } -+ var parsed = ParseDelimitedCommand( arguments, allowFlags: false ); -+ var expression = this.CompileRegularExpression( parsed.Pattern, cancellationToken ); -+ var command = string.IsNullOrWhiteSpace( parsed.Remainder ) ? "p" : parsed.Remainder; -+ var selectedIds = new List(); -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ this.ThrowIfInterrupted( cancellationToken ); -+ var line = this.Buffer.GetLine( address ); -+ var result = expression.Match( line.GetText(), cancellationToken: cancellationToken ); -+ if ( !result.IsSuccess ) { -+ throw new EditorCommandException( EditorDiagnosticCode.RegularExpression, result.Diagnostic?.ToString() ?? "Regular-expression matching failed." ); -+ } -+ if ( invert != result.IsMatch ) { -+ selectedIds.Add( line.Id ); -+ } -+ } -+ -+ this.globalExecutionActive = true; -+ try { -+ foreach ( var lineId in selectedIds ) { -+ this.ThrowIfInterrupted( cancellationToken ); -+ var address = this.Buffer.FindAddress( lineId ); -+ if ( 0 == address ) { -+ continue; -+ } -+ this.CurrentAddress = address; -+ await this.ExecuteCommandAsync( -+ command, -+ scriptRecords, -+ scriptIndex, -+ standardOutput, -+ standardError, -+ sourceName, -+ lineNumber, -+ captureUndo: false, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ } finally { -+ this.globalExecutionActive = false; -+ } -+ } -+ -+ private async ValueTask RunShellCapabilityAsync( -+ string command, -+ ReadOnlyMemory standardInput, -+ CancellationToken cancellationToken -+ ) { -+ try { -+ return await this.processAccess.RunShellAsync( -+ command, -+ standardInput, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } catch ( OperationCanceledException ) { -+ throw; -+ } catch ( Exception exception ) when ( -+ exception is IOException -+ or UnauthorizedAccessException -+ or System.ComponentModel.Win32Exception -+ or InvalidOperationException -+ ) { -+ throw new EditorCommandException( -+ exception is UnauthorizedAccessException -+ ? EditorDiagnosticCode.RestrictedOperation -+ : EditorDiagnosticCode.ProcessOperation, -+ exception.Message -+ ); -+ } -+ } -+ -+ private async ValueTask ExecuteShellAsync( -+ ParsedEditorRange parsedRange, -+ string arguments, -+ Stream standardOutput, -+ Stream standardError, -+ bool captureUndo, -+ CancellationToken cancellationToken -+ ) { -+ if ( !this.SecurityPolicy.AllowShellCommands ) { -+ throw new EditorCommandException( EditorDiagnosticCode.RestrictedOperation, "Shell commands are disabled by the editor security profile." ); -+ } -+ var command = arguments.Trim(); -+ if ( "!" == command ) { -+ command = this.lastShellCommand ?? throw new EditorCommandException( -+ EditorDiagnosticCode.ProcessOperation, -+ "No previous shell command." -+ ); -+ } else if ( 0 == command.Length ) { -+ throw new EditorCommandException( EditorDiagnosticCode.ProcessOperation, "A shell command is required." ); -+ } -+ this.lastShellCommand = command; -+ -+ if ( !parsedRange.IsSpecified ) { -+ var result = await this.RunShellCapabilityAsync( -+ command, -+ ReadOnlyMemory.Empty, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( result.Canceled ) { -+ throw new OperationCanceledException( cancellationToken ); -+ } -+ await standardOutput.WriteAsync( result.StandardOutput, cancellationToken ).ConfigureAwait( false ); -+ await standardError.WriteAsync( result.StandardError, cancellationToken ).ConfigureAwait( false ); -+ if ( 0 != ( result.ExitCode ?? 1 ) ) { -+ throw new EditorCommandException( EditorDiagnosticCode.ProcessOperation, "The shell command failed." ); -+ } -+ return; -+ } -+ -+ var range = this.ValidateRange( parsedRange.Range ); -+ await using var input = new MemoryStream(); -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ await input.WriteAsync( this.Buffer.GetLine( address ).Content, cancellationToken ).ConfigureAwait( false ); -+ await input.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ } -+ var process = await this.RunShellCapabilityAsync( -+ command, -+ input.ToArray(), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await standardError.WriteAsync( process.StandardError, cancellationToken ).ConfigureAwait( false ); -+ if ( process.Canceled ) { -+ throw new OperationCanceledException( cancellationToken ); -+ } -+ if ( 0 != ( process.ExitCode ?? 1 ) ) { -+ throw new EditorCommandException( EditorDiagnosticCode.ProcessOperation, "The filter command failed." ); -+ } -+ var replacement = await ReadRecordsFromMemoryAsync( process.StandardOutput, cancellationToken ).ConfigureAwait( false ); -+ if ( captureUndo ) { -+ this.CaptureUndo(); -+ } -+ var inserted = this.Buffer.Replace( range, replacement.Lines ); -+ this.CurrentAddress = 0 == inserted.Start -+ ? Math.Min( this.Buffer.Count, range.Start - 1 ) -+ : inserted.End; -+ this.FinalRecordTerminated = replacement.FinalRecordTerminated; -+ this.RemoveDanglingMarks(); -+ this.IsModified = true; -+ } -+ -+ private SubstitutionOutcome Substitute( -+ EditorAddressRange range, -+ string arguments, -+ CancellationToken cancellationToken -+ ) { -+ var parsed = ParseDelimitedCommand( arguments, allowFlags: true ); -+ var pattern = 0 == parsed.Pattern.Length -+ ? this.lastRegularExpression ?? throw new EditorCommandException( EditorDiagnosticCode.RegularExpression, "No previous regular expression." ) -+ : parsed.Pattern; -+ this.lastRegularExpression = pattern; -+ var replacement = parsed.Replacement ?? this.lastReplacement ?? string.Empty; -+ this.lastReplacement = replacement; -+ var flags = ParseSubstitutionFlags( parsed.Remainder ); -+ var expression = this.CompileRegularExpression( pattern, cancellationToken ); -+ var anyMatch = false; -+ var lastChanged = range.Start; -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ this.ThrowIfInterrupted( cancellationToken ); -+ var original = this.Buffer.GetLine( address ).GetText(); -+ var changed = ReplaceMatches( -+ original, -+ expression, -+ replacement, -+ flags, -+ cancellationToken -+ ); -+ if ( null == changed ) { -+ continue; -+ } -+ this.Buffer.SetContent( address, Encoding.UTF8.GetBytes( changed ) ); -+ anyMatch = true; -+ lastChanged = address; -+ } -+ return new SubstitutionOutcome( -+ anyMatch, -+ lastChanged, -+ flags.PrintChanged -+ ); -+ } -+ -+ private static string? ReplaceMatches( -+ string input, -+ ICompiledRegularExpression expression, -+ string replacement, -+ SubstitutionFlags flags, -+ CancellationToken cancellationToken -+ ) { -+ var output = new StringBuilder( input.Length ); -+ var searchStart = 0; -+ var copyStart = 0; -+ var occurrence = 0; -+ var replaced = false; -+ while ( input.Length >= searchStart ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ var remainder = input[ searchStart.. ]; -+ var result = expression.Match( remainder, cancellationToken: cancellationToken ); -+ if ( !result.IsSuccess ) { -+ throw new EditorCommandException( EditorDiagnosticCode.RegularExpression, result.Diagnostic?.ToString() ?? "Regular-expression matching failed." ); -+ } -+ if ( !result.IsMatch ) { -+ break; -+ } -+ var match = result.Match!; -+ var absoluteIndex = checked( searchStart + match.Index ); -+ occurrence++; -+ var shouldReplace = flags.Global -+ ? ( 0 == flags.Occurrence || occurrence >= flags.Occurrence ) -+ : ( 0 == flags.Occurrence ? 1 == occurrence : flags.Occurrence == occurrence ); -+ if ( shouldReplace ) { -+ output.Append( input, copyStart, absoluteIndex - copyStart ); -+ AppendReplacement( output, replacement, match ); -+ copyStart = checked( absoluteIndex + match.Length ); -+ replaced = true; -+ if ( !flags.Global ) { -+ break; -+ } -+ } -+ var advance = 0 == match.Length ? 1 : match.Length; -+ searchStart = checked( absoluteIndex + advance ); -+ if ( input.Length < searchStart ) { -+ break; -+ } -+ } -+ if ( !replaced ) { -+ return null; -+ } -+ output.Append( input, copyStart, input.Length - copyStart ); -+ return output.ToString(); -+ } -+ -+ private static void AppendReplacement( -+ StringBuilder output, -+ string replacement, -+ RegularExpressionMatch match -+ ) { -+ var escaped = false; -+ foreach ( var character in replacement ) { -+ if ( escaped ) { -+ if ( ( '1' <= character ) && ( '9' >= character ) ) { -+ var captureIndex = character - '1'; -+ if ( match.Captures.Count > captureIndex ) { -+ var capture = match.Captures[ captureIndex ]; -+ if ( capture.Success ) { -+ output.Append( capture.Value ); -+ } -+ } -+ } else if ( 'n' == character ) { -+ output.Append( '\n' ); -+ } else { -+ output.Append( character ); -+ } -+ escaped = false; -+ continue; -+ } -+ if ( '\\' == character ) { -+ escaped = true; -+ } else if ( '&' == character ) { -+ output.Append( match.Value ); -+ } else { -+ output.Append( character ); -+ } -+ } -+ if ( escaped ) { -+ output.Append( '\\' ); -+ } -+ } -+ -+ private ICompiledRegularExpression CompileRegularExpression( -+ string pattern, -+ CancellationToken cancellationToken -+ ) { -+ if ( 0 == pattern.Length ) { -+ pattern = this.lastRegularExpression ?? throw new EditorCommandException( -+ EditorDiagnosticCode.RegularExpression, -+ "No previous regular expression." -+ ); -+ } else { -+ this.lastRegularExpression = pattern; -+ } -+ var result = this.regularExpressionProvider.Compile( -+ pattern, -+ cancellationToken: cancellationToken -+ ); -+ if ( !result.IsSuccess || null == result.Expression ) { -+ throw new EditorCommandException( -+ EditorDiagnosticCode.RegularExpression, -+ result.Diagnostic?.ToString() ?? "Invalid regular expression." -+ ); -+ } -+ return result.Expression; -+ } -+ -+ private int SearchAddress( -+ string pattern, -+ bool reverse, -+ int startAddress, -+ CancellationToken cancellationToken -+ ) { -+ if ( 0 == this.Buffer.Count ) { -+ throw new EditorParseException( "The buffer is empty." ); -+ } -+ var expression = this.CompileRegularExpression( pattern, cancellationToken ); -+ for ( var offset = 1; this.Buffer.Count >= offset; offset++ ) { -+ this.ThrowIfInterrupted( cancellationToken ); -+ var address = reverse -+ ? startAddress - offset -+ : startAddress + offset; -+ while ( 1 > address ) { -+ address += this.Buffer.Count; -+ } -+ while ( this.Buffer.Count < address ) { -+ address -= this.Buffer.Count; -+ } -+ var result = expression.Match( this.Buffer.GetLine( address ).GetText(), cancellationToken: cancellationToken ); -+ if ( result.IsSuccess && result.IsMatch ) { -+ return address; -+ } -+ if ( !result.IsSuccess ) { -+ throw new EditorParseException( result.Diagnostic?.ToString() ?? "Regular-expression matching failed." ); -+ } -+ } -+ throw new EditorParseException( "No matching line." ); -+ } -+ -+ private int ResolveMark( -+ char mark -+ ) { -+ if ( !this.marks.TryGetValue( mark, out var lineId ) ) { -+ throw new EditorParseException( "The mark is not set." ); -+ } -+ var address = this.Buffer.FindAddress( lineId ); -+ if ( 0 == address ) { -+ this.marks.Remove( mark ); -+ throw new EditorParseException( "The marked line no longer exists." ); -+ } -+ return address; -+ } -+ -+ private int ParseDestinationAddress( -+ string text, -+ CancellationToken cancellationToken -+ ) { -+ var parser = new EditorAddressParser( -+ text, -+ this.CurrentAddress, -+ this.Buffer.Count, -+ this.ResolveMark, -+ ( pattern, reverse, startAddress ) => this.SearchAddress( -+ pattern, -+ reverse, -+ startAddress, -+ cancellationToken -+ ) -+ ); -+ ParsedEditorRange parsed; -+ try { -+ parsed = parser.ParseRange(); -+ } catch ( EditorParseException exception ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, exception.Message ); -+ } -+ if ( !parsed.IsSpecified || parsed.Range.Start != parsed.Range.End ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, "A destination address is required." ); -+ } -+ if ( text.AsSpan( parser.Position ).Trim().Length != 0 ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, "Invalid text after destination address." ); -+ } -+ return this.ValidateSingleAddress( parsed.Range.Start, allowZero: true ); -+ } -+ -+ private EditorAddressRange ResolveRange( -+ ParsedEditorRange parsed, -+ int defaultStart, -+ int defaultEnd -+ ) { -+ var range = parsed.IsSpecified -+ ? parsed.Range -+ : new EditorAddressRange( defaultStart, defaultEnd ); -+ return this.ValidateRange( range ); -+ } -+ -+ private int ResolveSingleAddress( -+ ParsedEditorRange parsed, -+ int defaultAddress, -+ bool allowZero -+ ) { -+ if ( parsed.IsSpecified && parsed.Range.Start != parsed.Range.End ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, "Only one address is permitted." ); -+ } -+ return this.ValidateSingleAddress( -+ parsed.IsSpecified ? parsed.Range.End : defaultAddress, -+ allowZero -+ ); -+ } -+ -+ private EditorAddressRange ValidateRange( -+ EditorAddressRange range -+ ) { -+ if ( -+ ( 1 > range.Start ) -+ || ( range.Start > range.End ) -+ || ( this.Buffer.Count < range.End ) -+ ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, "Invalid address range." ); -+ } -+ return range; -+ } -+ -+ private int ValidateSingleAddress( -+ int address, -+ bool allowZero -+ ) { -+ var minimum = allowZero ? 0 : 1; -+ if ( ( minimum > address ) || ( this.Buffer.Count < address ) ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidAddress, "Invalid address." ); -+ } -+ return address; -+ } -+ -+ private static string ParseFileNameArgument( -+ string arguments -+ ) { -+ // Leading whitespace separates the command from its filename. Trailing -+ // whitespace is filename data and must remain visible to security policy. -+ return arguments.TrimStart(); -+ } -+ -+ private string ResolveFileName( -+ string arguments, -+ bool requireName -+ ) { -+ var candidate = ParseFileNameArgument( arguments ); -+ if ( 0 == candidate.Length ) { -+ if ( null != this.RememberedFileName ) { -+ return this.RememberedFileName; -+ } -+ if ( requireName ) { -+ throw new EditorCommandException( EditorDiagnosticCode.FileName, "A filename is required." ); -+ } -+ } -+ if ( this.SecurityPolicy.IsRestricted && !this.SecurityPolicy.AllowPathnames ) { -+ ValidateRestrictedFileName( candidate ); -+ } -+ return candidate; -+ } -+ -+ private void SetRememberedFileName( -+ string path -+ ) { -+ if ( !this.SecurityPolicy.AllowRememberedFileName ) { -+ return; -+ } -+ if ( this.SecurityPolicy.IsRestricted ) { -+ ValidateRestrictedFileName( path ); -+ } -+ this.RememberedFileName = path; -+ } -+ -+ private static void ValidateRestrictedFileName( -+ string candidate -+ ) { -+ if ( !IsRestrictedFileName( candidate ) ) { -+ throw new EditorCommandException( -+ EditorDiagnosticCode.RestrictedOperation, -+ "Restricted mode permits only a simple filename in the captured working directory." -+ ); -+ } -+ } -+ -+ private static bool IsRestrictedFileName( -+ string candidate -+ ) => EditorRestrictedPath.IsSimpleFileName( candidate ); -+ -+ private void ValidateRestrictedDispatch( -+ char command, -+ string arguments -+ ) { -+ if ( !this.SecurityPolicy.IsRestricted ) { -+ return; -+ } -+ if ( '!' == command ) { -+ throw new EditorCommandException( -+ EditorDiagnosticCode.RestrictedOperation, -+ "Shell commands are disabled by the editor security profile." -+ ); -+ } -+ if ( command is 'e' or 'E' or 'r' or 'w' or 'W' or 'f' ) { -+ var candidate = ParseFileNameArgument( arguments ); -+ if ( 0 < candidate.Length ) { -+ ValidateRestrictedFileName( candidate ); -+ } -+ } -+ if ( command is 'g' or 'v' ) { -+ var parsed = ParseDelimitedCommand( arguments, allowFlags: false ); -+ var nested = string.IsNullOrWhiteSpace( parsed.Remainder ) ? "p" : parsed.Remainder; -+ this.ValidateRestrictedCommandText( nested ); -+ } -+ } -+ -+ private void ValidateRestrictedCommandText( -+ string commandText -+ ) { -+ var position = FindCommandIndex( commandText ); -+ if ( 0 > position ) { -+ return; -+ } -+ var command = commandText[ position++ ]; -+ this.ValidateRestrictedDispatch( command, commandText[ position.. ] ); -+ } -+ -+ private static int FindCommandIndex( -+ string text -+ ) { -+ var escaped = false; -+ var delimiter = '\0'; -+ var afterMark = false; -+ for ( var index = 0; text.Length > index; index++ ) { -+ var character = text[ index ]; -+ if ( '\0' != delimiter ) { -+ if ( escaped ) { -+ escaped = false; -+ continue; -+ } -+ if ( '\\' == character ) { -+ escaped = true; -+ continue; -+ } -+ if ( delimiter == character ) { -+ delimiter = '\0'; -+ } -+ continue; -+ } -+ if ( afterMark ) { -+ afterMark = false; -+ continue; -+ } -+ if ( '\'' == character ) { -+ afterMark = true; -+ continue; -+ } -+ if ( character is '/' or '?' ) { -+ delimiter = character; -+ continue; -+ } -+ if ( char.IsLetter( character ) || character is '!' or '=' or '#' ) { -+ return index; -+ } -+ } -+ return -1; -+ } -+ -+ private void CaptureUndo() { -+ this.undoSnapshot = new EditorSnapshot( -+ this.Buffer.CaptureSnapshot(), -+ this.CurrentAddress, -+ this.IsModified, -+ this.RememberedFileName, -+ this.FinalRecordTerminated, -+ this.marks.ToDictionary( pair => pair.Key, pair => pair.Value ), -+ this.cutBuffer.Select( item => new ReadOnlyMemory( item.ToArray() ) ).ToArray() -+ ); -+ } -+ -+ -+ private void Undo() { -+ var snapshot = this.undoSnapshot ?? throw new EditorCommandException( -+ EditorDiagnosticCode.InvalidCommand, -+ "Nothing to undo." -+ ); -+ var current = new EditorSnapshot( -+ this.Buffer.CaptureSnapshot(), -+ this.CurrentAddress, -+ this.IsModified, -+ this.RememberedFileName, -+ this.FinalRecordTerminated, -+ this.marks.ToDictionary( pair => pair.Key, pair => pair.Value ), -+ this.cutBuffer.Select( item => new ReadOnlyMemory( item.ToArray() ) ).ToArray() -+ ); -+ this.RestoreSnapshot( snapshot ); -+ this.undoSnapshot = current; -+ } -+ -+ private void RestoreSnapshot( -+ EditorSnapshot snapshot -+ ) { -+ this.Buffer.RestoreSnapshot( snapshot.Buffer ); -+ this.CurrentAddress = snapshot.CurrentAddress; -+ this.IsModified = snapshot.IsModified; -+ this.RememberedFileName = snapshot.RememberedFileName; -+ this.FinalRecordTerminated = snapshot.FinalRecordTerminated; -+ this.marks.Clear(); -+ foreach ( var pair in snapshot.Marks ) { -+ this.marks[ pair.Key ] = pair.Value; -+ } -+ this.cutBuffer.Clear(); -+ this.cutBuffer.AddRange( snapshot.CutBuffer.Select( item => new ReadOnlyMemory( item.ToArray() ) ) ); -+ } -+ -+ private void RemoveDanglingMarks() { -+ foreach ( var mark in this.marks.Keys.ToArray() ) { -+ if ( 0 == this.Buffer.FindAddress( this.marks[ mark ] ) ) { -+ this.marks.Remove( mark ); -+ } -+ } -+ } -+ -+ private void ThrowIfInterrupted( -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ if ( EditorSignal.None != this.pendingSignal ) { -+ throw new OperationCanceledException( cancellationToken ); -+ } -+ } -+ -+ private async ValueTask PrintRangeAsync( -+ EditorAddressRange range, -+ Stream output, -+ PrintMode mode, -+ CancellationToken cancellationToken -+ ) { -+ this.ValidateRange( range ); -+ for ( var address = range.Start; range.End >= address; address++ ) { -+ this.ThrowIfInterrupted( cancellationToken ); -+ var content = this.Buffer.GetLine( address ).Content; -+ if ( PrintMode.Numbered == mode ) { -+ await output.WriteAsync( -+ Encoding.UTF8.GetBytes( string.Concat( address.ToString( CultureInfo.InvariantCulture ), "\t" ) ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ if ( PrintMode.List == mode ) { -+ await output.WriteAsync( -+ Encoding.UTF8.GetBytes( RenderListLine( content.Span ) ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await output.WriteAsync( new byte[] { (byte)'$', (byte)'\n' }, cancellationToken ).ConfigureAwait( false ); -+ } else { -+ await output.WriteAsync( content, cancellationToken ).ConfigureAwait( false ); -+ await output.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ } -+ this.CurrentAddress = address; -+ } -+ } -+ -+ private static string RenderListLine( -+ ReadOnlySpan content -+ ) { -+ var builder = new StringBuilder(); -+ foreach ( var value in content ) { -+ switch ( value ) { -+ case (byte)'\\': -+ builder.Append( "\\\\" ); -+ break; -+ case (byte)'\t': -+ builder.Append( "\\t" ); -+ break; -+ case (byte)'\r': -+ builder.Append( "\\r" ); -+ break; -+ case < 0x20: -+ case 0x7f: -+ builder.Append( "\\x" ); -+ builder.Append( value.ToString( "x2", CultureInfo.InvariantCulture ) ); -+ break; -+ default: -+ builder.Append( (char)value ); -+ break; -+ } -+ } -+ return builder.ToString(); -+ } -+ -+ private static async ValueTask WriteTextLineAsync( -+ Stream output, -+ string text, -+ CancellationToken cancellationToken -+ ) { -+ await output.WriteAsync( Encoding.UTF8.GetBytes( text ), cancellationToken ).ConfigureAwait( false ); -+ await output.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private static async ValueTask>> ReadScriptAsync( -+ Stream stream, -+ CancellationToken cancellationToken -+ ) { -+ using var reader = new ByteRecordReader( stream ); -+ var records = new List>(); -+ while ( true ) { -+ var record = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == record ) { -+ break; -+ } -+ var content = record.Content; -+ if ( record.IsTerminated && !content.IsEmpty && (byte)'\r' == content.Span[ ^1 ] ) { -+ content = content[ ..^1 ]; -+ } -+ records.Add( content.ToArray() ); -+ } -+ return records.AsReadOnly(); -+ } -+ -+ private static DataBlock ReadDataBlock( -+ IReadOnlyList> scriptRecords, -+ int commandIndex -+ ) { -+ var lines = new List>(); -+ for ( var index = commandIndex + 1; scriptRecords.Count > index; index++ ) { -+ var record = scriptRecords[ index ]; -+ if ( record.Span.SequenceEqual( new byte[] { (byte)'.' } ) ) { -+ return new DataBlock( lines.AsReadOnly(), index ); -+ } -+ lines.Add( record.ToArray() ); -+ } -+ throw new EditorCommandException( -+ EditorDiagnosticCode.UnexpectedEndOfInput, -+ "The command data block is not terminated by a single period." -+ ); -+ } -+ -+ private static DelimitedCommand ParseDelimitedCommand( -+ string text, -+ bool allowFlags -+ ) { -+ var position = 0; -+ while ( ( text.Length > position ) && char.IsWhiteSpace( text[ position ] ) ) { -+ position++; -+ } -+ if ( text.Length <= position ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "A delimiter is required." ); -+ } -+ var delimiter = text[ position++ ]; -+ if ( char.IsLetterOrDigit( delimiter ) || '\\' == delimiter || char.IsWhiteSpace( delimiter ) ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "Invalid command delimiter." ); -+ } -+ var pattern = ReadDelimitedPart( text, ref position, delimiter ); -+ if ( !allowFlags ) { -+ return new DelimitedCommand( pattern, null, text[ position.. ].TrimStart() ); -+ } -+ var replacement = ReadDelimitedPart( text, ref position, delimiter ); -+ return new DelimitedCommand( pattern, replacement, text[ position.. ].Trim() ); -+ } -+ -+ private static string ReadDelimitedPart( -+ string text, -+ ref int position, -+ char delimiter -+ ) { -+ var builder = new StringBuilder(); -+ var escaped = false; -+ while ( text.Length > position ) { -+ var character = text[ position++ ]; -+ if ( escaped ) { -+ if ( delimiter != character ) { -+ builder.Append( '\\' ); -+ } -+ builder.Append( character ); -+ escaped = false; -+ continue; -+ } -+ if ( '\\' == character ) { -+ escaped = true; -+ continue; -+ } -+ if ( delimiter == character ) { -+ return builder.ToString(); -+ } -+ builder.Append( character ); -+ } -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "Unterminated delimited command." ); -+ } -+ -+ private static SubstitutionFlags ParseSubstitutionFlags( -+ string text -+ ) { -+ var global = false; -+ var print = false; -+ var occurrence = 0; -+ foreach ( var character in text ) { -+ if ( 'g' == character ) { -+ global = true; -+ } else if ( 'p' == character ) { -+ print = true; -+ } else if ( char.IsAsciiDigit( character ) ) { -+ var digit = character - '0'; -+ if ( occurrence > ( int.MaxValue - digit ) / 10 ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "The substitution occurrence is too large." ); -+ } -+ occurrence = occurrence * 10 + digit; -+ } else if ( !char.IsWhiteSpace( character ) ) { -+ throw new EditorCommandException( EditorDiagnosticCode.InvalidCommand, "Invalid substitution flag." ); -+ } -+ } -+ return new SubstitutionFlags( global, occurrence, print ); -+ } -+ -+ private static async ValueTask ReadRecordsFromMemoryAsync( -+ ReadOnlyMemory content, -+ CancellationToken cancellationToken -+ ) { -+ await using var stream = new MemoryStream( content.ToArray(), writable: false ); -+ using var reader = new ByteRecordReader( stream ); -+ var lines = new List>(); -+ var terminated = true; -+ while ( true ) { -+ var record = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == record ) { -+ break; -+ } -+ lines.Add( record.Content.ToArray() ); -+ terminated = record.IsTerminated; -+ } -+ return new EditorFileReadResult( lines.AsReadOnly(), 0 == lines.Count || terminated, content.Length ); -+ } -+ -+ private sealed record EditorSnapshot( -+ BufferSnapshot Buffer, -+ int CurrentAddress, -+ bool IsModified, -+ string? RememberedFileName, -+ bool FinalRecordTerminated, -+ IReadOnlyDictionary Marks, -+ IReadOnlyList> CutBuffer -+ ); -+ -+ private readonly record struct CommandOutcome( -+ int LastConsumedRecord, -+ bool QuitRequested -+ ); -+ -+ private readonly record struct DataBlock( -+ IReadOnlyList> Lines, -+ int LastConsumedRecord -+ ); -+ -+ private readonly record struct DelimitedCommand( -+ string Pattern, -+ string? Replacement, -+ string Remainder -+ ); -+ -+ private readonly record struct SubstitutionFlags( -+ bool Global, -+ int Occurrence, -+ bool PrintChanged -+ ); -+ -+ private readonly record struct SubstitutionOutcome( -+ bool IsMatch, -+ int LastChangedAddress, -+ bool PrintChanged -+ ); -+ -+ private enum PrintMode { -+ Plain, -+ Numbered, -+ List -+ } -+} -+ -+/// Represents a controlled Ed command failure. -+internal sealed class EditorCommandException : Exception { -+ /// Initializes a controlled command exception. -+ /// The stable diagnostic category. -+ /// The controlled command diagnostic. -+ internal EditorCommandException( -+ EditorDiagnosticCode code, -+ string message -+ ) : base( message ) { -+ this.Code = code; -+ } -+ -+ /// Gets the stable diagnostic category. -+ internal EditorDiagnosticCode Code { -+ get; -+ } -+} -diff --git a/Icod.LineEditor.Ed.Shared/src/EditorModels.cs b/Icod.LineEditor.Ed.Shared/src/EditorModels.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..f6117558c2ef3a91cc4560a20a1401980c27db7c ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/src/EditorModels.cs -@@ -0,0 +1,144 @@ -+namespace Icod.LineEditor.Ed; -+ -+using System.Text; -+ -+/// Identifies the exit status returned by the reusable Ed engine. -+public enum EditorExitStatus { -+ /// The script completed successfully. -+ Success = 0, -+ /// The script encountered a controlled command or data error. -+ Error = 1, -+ /// The script was interrupted or canceled. -+ Interrupted = 2 -+} -+ -+/// Identifies a signal delivered to an editor session. -+public enum EditorSignal { -+ /// No signal is pending. -+ None, -+ /// An interrupt was requested. -+ Interrupt, -+ /// A hangup was requested. -+ Hangup, -+ /// Termination was requested. -+ Terminate -+} -+ -+/// Identifies a controlled Ed-engine diagnostic. -+public enum EditorDiagnosticCode { -+ /// An address or range is invalid. -+ InvalidAddress, -+ /// A command is unknown or malformed. -+ InvalidCommand, -+ /// A regular expression could not be compiled or matched. -+ RegularExpression, -+ /// A filename is required or denied. -+ FileName, -+ /// A file operation failed. -+ FileOperation, -+ /// A process operation is denied or failed. -+ ProcessOperation, -+ /// A restricted security policy denied an operation. -+ RestrictedOperation, -+ /// The buffer has unsaved changes. -+ ModifiedBuffer, -+ /// The script ended while command data was incomplete. -+ UnexpectedEndOfInput, -+ /// The operation was canceled or interrupted. -+ Interrupted -+} -+ -+/// Represents one deterministic editor diagnostic. -+/// The stable diagnostic category. -+/// The diagnostic text. -+/// The script source name, when known. -+/// The one-based script line number, when known. -+public sealed record EditorDiagnostic( -+ EditorDiagnosticCode Code, -+ string Message, -+ string? SourceName = null, -+ long? LineNumber = null -+); -+ -+/// Represents a stable line stored in the mutable editor buffer. -+public sealed class EditorLine { -+ private readonly byte[] content; -+ -+ /// Initializes a line from authoritative bytes. -+ /// The stable nonzero line identity. -+ /// The line content without its record separator. -+ public EditorLine( -+ long id, -+ ReadOnlyMemory content -+ ) { -+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero( id ); -+ this.Id = id; -+ this.content = content.ToArray(); -+ } -+ -+ /// Gets the stable identity retained across moves and undo snapshots. -+ public long Id { -+ get; -+ } -+ -+ /// Gets a copy-free view of the line bytes. -+ public ReadOnlyMemory Content => this.content; -+ -+ /// Decodes the line using UTF-8 with replacement fallback. -+ /// The decoded line. -+ public string GetText() => Encoding.UTF8.GetString( this.content ); -+} -+ -+/// Represents an inclusive one-based editor address range. -+/// The first line address. -+/// The last line address. -+public readonly record struct EditorAddressRange( -+ int Start, -+ int End -+) { -+ /// Gets the number of addressed lines. -+ public int Count => checked( this.End - this.Start + 1 ); -+} -+ -+/// Represents the result of executing an editor script. -+/// The controlled exit status. -+/// The last diagnostic, when execution failed. -+/// Whether a quit command ended execution. -+/// The signal that ended execution, when applicable. -+public sealed record EditorExecutionResult( -+ EditorExitStatus ExitStatus, -+ EditorDiagnostic? Diagnostic, -+ bool QuitRequested, -+ EditorSignal Signal -+) { -+ /// Gets whether execution completed successfully. -+ public bool IsSuccess => EditorExitStatus.Success == this.ExitStatus; -+} -+ -+/// Represents file content returned through an editor file capability. -+/// The records without line separators. -+/// Whether the final record was terminated. -+/// The number of bytes read. -+public sealed record EditorFileReadResult( -+ IReadOnlyList> Lines, -+ bool FinalRecordTerminated, -+ long ByteCount -+); -+ -+/// Represents the result of an editor write operation. -+/// The number of content and separator bytes written. -+public sealed record EditorFileWriteResult( -+ long ByteCount -+); -+ -+/// Represents the result of an editor shell or filter process. -+/// The child exit code, when one was produced. -+/// Whether the process was canceled. -+/// The captured standard-output bytes. -+/// The captured standard-error bytes. -+public sealed record EditorProcessResult( -+ int? ExitCode, -+ bool Canceled, -+ ReadOnlyMemory StandardOutput, -+ ReadOnlyMemory StandardError -+); -diff --git a/Icod.LineEditor.Ed.Shared/src/README.md b/Icod.LineEditor.Ed.Shared/src/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..beead8001ec4c432a001037f19bf66117294b2c1 ---- /dev/null -+++ b/Icod.LineEditor.Ed.Shared/src/README.md -@@ -0,0 +1,13 @@ -+# Ed shared engine source -+ -+This directory contains the Phase LE6 mutable Ed/Red engine. -+ -+| File | Responsibility | -+|---|---| -+| `EditorModels.cs` | Public result, diagnostic, signal, line, range, file, and process models. | -+| `EditorBuffer.cs` | Segmented mutable line storage, stable line identity, movement, copying, joining, and snapshots. | -+| `EditorAddressParser.cs` | Ed-specific addresses, offsets, marks, forward/reverse searches, and ranges. | -+| `EditorCapabilities.cs` | Immutable security profiles and injected standard, restricted, and denied file/process capabilities. | -+| `EditorEngine.cs` | Session state, command dispatch, substitutions, global execution, undo, file/process effects, diagnostics, cancellation, and signals. | -+ -+Sed-specific pattern/hold space, range state, and streaming-cycle behavior do not belong here. Cross-suite regex, records, process execution, secure temporary objects, and filesystem durability remain in the current Shared incubation project. -diff --git a/Icod.LineEditor.sln b/Icod.LineEditor.sln -new file mode 100644 -index 0000000000000000000000000000000000000000..aedb4e0ef8148d591500eedfa57b7dcf4fc162cc ---- /dev/null -+++ b/Icod.LineEditor.sln -@@ -0,0 +1,88 @@ -+Microsoft Visual Studio Solution File, Format Version 12.00 -+# Visual Studio Version 17 -+VisualStudioVersion = 17.0.31903.59 -+MinimumVisualStudioVersion = 10.0.40219.1 -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Ed.Shared", "Icod.LineEditor.Ed.Shared\Icod.LineEditor.Ed.Shared.csproj", "{8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Ed", "ed\Icod.LineEditor.Ed.csproj", "{C4010FAE-3D10-4D62-BDBE-1563236AA9D8}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Red", "red\Icod.LineEditor.Red.csproj", "{07DD9C95-C474-4801-853D-7D1E44455567}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Sed", "sed\Icod.LineEditor.Sed.csproj", "{58B05E59-4BFE-41B1-9E0C-F161547F7362}" -+EndProject -+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9E7C04E6-2E76-4D11-89BD-14C238881678}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Ed.Shared.Tests", "tests\Ed.Shared.Tests\Icod.LineEditor.Ed.Shared.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0101}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Ed.Tests", "tests\Ed.Tests\Icod.LineEditor.Ed.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0102}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Red.Tests", "tests\Red.Tests\Icod.LineEditor.Red.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0103}" -+EndProject -+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.LineEditor.Sed.Tests", "tests\Sed.Tests\Icod.LineEditor.Sed.Tests.csproj", "{F05B4136-23C6-4CF5-8A6A-920F20DC0006}" -+EndProject -+Global -+ GlobalSection(SolutionConfigurationPlatforms) = preSolution -+ Debug|Any CPU = Debug|Any CPU -+ Staging|Any CPU = Staging|Any CPU -+ Release|Any CPU = Release|Any CPU -+ EndGlobalSection -+ GlobalSection(ProjectConfigurationPlatforms) = postSolution -+ {8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {8E0ED99C-7ACD-483A-A9F0-6C1DB92F08AF}.Release|Any CPU.Build.0 = Release|Any CPU -+ {C4010FAE-3D10-4D62-BDBE-1563236AA9D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {C4010FAE-3D10-4D62-BDBE-1563236AA9D8}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {C4010FAE-3D10-4D62-BDBE-1563236AA9D8}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {C4010FAE-3D10-4D62-BDBE-1563236AA9D8}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {C4010FAE-3D10-4D62-BDBE-1563236AA9D8}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {C4010FAE-3D10-4D62-BDBE-1563236AA9D8}.Release|Any CPU.Build.0 = Release|Any CPU -+ {07DD9C95-C474-4801-853D-7D1E44455567}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {07DD9C95-C474-4801-853D-7D1E44455567}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {07DD9C95-C474-4801-853D-7D1E44455567}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {07DD9C95-C474-4801-853D-7D1E44455567}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {07DD9C95-C474-4801-853D-7D1E44455567}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {07DD9C95-C474-4801-853D-7D1E44455567}.Release|Any CPU.Build.0 = Release|Any CPU -+ {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {58B05E59-4BFE-41B1-9E0C-F161547F7362}.Release|Any CPU.Build.0 = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101}.Release|Any CPU.Build.0 = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102}.Release|Any CPU.Build.0 = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103}.Release|Any CPU.Build.0 = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Debug|Any CPU.Build.0 = Debug|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Staging|Any CPU.ActiveCfg = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Staging|Any CPU.Build.0 = Staging|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Release|Any CPU.ActiveCfg = Release|Any CPU -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006}.Release|Any CPU.Build.0 = Release|Any CPU -+ EndGlobalSection -+ GlobalSection(SolutionProperties) = preSolution -+ HideSolutionNode = FALSE -+ EndGlobalSection -+ GlobalSection(NestedProjects) = preSolution -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0101} = {9E7C04E6-2E76-4D11-89BD-14C238881678} -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0102} = {9E7C04E6-2E76-4D11-89BD-14C238881678} -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0103} = {9E7C04E6-2E76-4D11-89BD-14C238881678} -+ {F05B4136-23C6-4CF5-8A6A-920F20DC0006} = {9E7C04E6-2E76-4D11-89BD-14C238881678} -+ EndGlobalSection -+EndGlobal -\ No newline at end of file -diff --git a/README.md b/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..d4a9dd409911a4b5fb0dd68e09c029f21c07f35f ---- /dev/null -+++ b/README.md -@@ -0,0 +1,44 @@ -+# Icod.LineEditor -+ -+Managed C#/.NET implementations of the classic line-oriented Unix editors `ed`, `red`, and `sed`. -+ -+This repository is the permanent home of the LineEditor family extracted from `Icod.CoreUtils` during Completion Gate G7. -+ -+## Projects -+ -+- `Icod.LineEditor.Ed.Shared` ├óΓé¼ΓÇ¥ the shared editor engine and capability boundary used by `ed` and `red`. -+- `ed` / `Icod.LineEditor.Ed` ├óΓé¼ΓÇ¥ the standard line editor. -+- `red` / `Icod.LineEditor.Red` ├óΓé¼ΓÇ¥ the restricted `ed` front end, retaining the same shared engine with restricted capabilities. -+- `sed` / `Icod.LineEditor.Sed` ├óΓé¼ΓÇ¥ the stream editor. `sed` remains a separate engine and does not depend on Ed.Shared. -+ -+There is intentionally no general `Icod.LineEditor.Shared` project. -+ -+## Platform and toolchain -+ -+- .NET 10 (`net10.0`) -+- C# 13 -+- Windows, Linux, and macOS -+- Debug, Staging, and Release configurations -+- Release builds treat warnings as errors except `CS1591` -+ -+Cross-suite infrastructure is consumed from the published `Icod.CommandFramework` package. The LineEditor repository has no source-tree dependency on `Icod.CoreUtils.Shared`. -+ -+## Build and test -+ -+```text -+dotnet restore Icod.LineEditor.sln -+dotnet build Icod.LineEditor.sln -c Staging --no-restore -+dotnet test Icod.LineEditor.sln -c Staging --no-build -+``` -+ -+`build.cmd` and `build.sh` perform the same clean/restore/build/test sequence; pass a configuration name as the first argument to override the default `Staging` configuration. -+ -+GitHub Actions validates pull requests and pushes to `main` on `windows-latest`, `ubuntu-latest`, and `macos-latest`. -+ -+## Extraction provenance -+ -+The initial G7 import was reviewed against `Icod.CoreUtils` commit `4ee41aa1dc1c549f85efab6e5fa156a3dfc7271b`. Historical LineEditor architecture, audit, migration, and Batch 34 design notes are retained under `docs/history/`. -+ -+## License -+ -+GPL-3.0. See `LICENSE`. -\ No newline at end of file -diff --git a/build.cmd b/build.cmd -new file mode 100644 -index 0000000000000000000000000000000000000000..8f1211c9667b69808addc696f231475447b36175 ---- /dev/null -+++ b/build.cmd -@@ -0,0 +1,10 @@ -+@echo off -+setlocal -+set CONFIGURATION=%~1 -+if "%CONFIGURATION%"=="" set CONFIGURATION=Staging -+ -+dotnet clean Icod.LineEditor.sln -c %CONFIGURATION% || exit /b %errorlevel% -+dotnet restore Icod.LineEditor.sln || exit /b %errorlevel% -+dotnet build Icod.LineEditor.sln -c %CONFIGURATION% --no-restore || exit /b %errorlevel% -+dotnet test Icod.LineEditor.sln -c %CONFIGURATION% --no-build || exit /b %errorlevel% -+endlocal -\ No newline at end of file -diff --git a/build.sh b/build.sh -new file mode 100644 -index 0000000000000000000000000000000000000000..2d216b455d7ccf4732e1de24e4f137a53c3bae37 ---- /dev/null -+++ b/build.sh -@@ -0,0 +1,9 @@ -+#!/usr/bin/env bash -+set -euo pipefail -+ -+configuration="${1:-Staging}" -+ -+dotnet clean Icod.LineEditor.sln -c "$configuration" -+dotnet restore Icod.LineEditor.sln -+dotnet build Icod.LineEditor.sln -c "$configuration" --no-restore -+dotnet test Icod.LineEditor.sln -c "$configuration" --no-build -\ No newline at end of file -diff --git a/docs/history/Icod.LineEditor-Informed-Architecture-Plan.md b/docs/history/Icod.LineEditor-Informed-Architecture-Plan.md -new file mode 100644 -index 0000000000000000000000000000000000000000..7f96aa21125c174411170fd003d06085c90358c9 ---- /dev/null -+++ b/docs/history/Icod.LineEditor-Informed-Architecture-Plan.md -@@ -0,0 +1,1456 @@ -+# Icod.LineEditor Revised Repository-Informed Architecture and Refactoring Plan -+ -+## Status of this document -+ -+This document supersedes the earlier proposed `Icod.LineEditor` architecture plan. -+ -+It is based on an inspection of the `main` branch of the current repository and roadmap as they stood on July 30, 2026: -+ -+- [Icod.CoreUtils repository](https://github.com/uniblab/Icod.CoreUtils) -+- [Icod.CoreUtils Audit and Refactor Roadmap](https://github.com/uniblab/Icod.CoreUtils/blob/main/Icod.CoreUtils-Audit-and-Refactor-Roadmap.md) -+- [Current Shared project README](https://github.com/uniblab/Icod.CoreUtils/blob/main/Shared/README.md) -+- [Current Sed command implementation](https://github.com/uniblab/Icod.CoreUtils/blob/main/sed/src/Command.cs) -+- [Current Sed project](https://github.com/uniblab/Icod.CoreUtils/blob/main/sed/Icod.LineEditor.Sed.csproj) -+- [Current Sed tests](https://github.com/uniblab/Icod.CoreUtils/blob/main/tests/Sed.Tests/src/SedCommandTests.cs) -+- [Current Ed command implementation](https://github.com/uniblab/Icod.CoreUtils/blob/main/ed/src/Command.cs) -+- [Current Ed project](https://github.com/uniblab/Icod.CoreUtils/blob/main/ed/Icod.LineEditor.Ed.csproj) -+- [Current Red project](https://github.com/uniblab/Icod.CoreUtils/blob/main/red/Icod.LineEditor.Red.csproj) -+ -+The authoritative upstream baselines currently relevant to the family are: -+ -+- [GNU ed 1.22.5 manual](https://www.gnu.org/software/ed/manual/ed_manual.html); -+- [GNU sed 4.10 release archive](https://ftp.gnu.org/gnu/sed/); -+- the exact pinned versions recorded in the repository's upstream-version ledger and roadmap. -+ -+The public command classes remain exactly: -+ -+```text -+Icod.LineEditor.Ed.Command -+Icod.LineEditor.Red.Command -+Icod.LineEditor.Sed.Command -+``` -+ -+No `EdCommand`, `RedCommand`, or `SedCommand` classes are proposed. -+ -+## LE0 through LE10 implementation status -+ -+Phase LE0 was completed on August 4, 2026. Project and solution identities now follow the architecture below, Ed and Red explicitly use C# 13, the Red project file follows the repository's UTF-8-without-BOM convention, and the pre-LE1 source and three-runner test state is recorded in [`Icod.LineEditor-LE0-Baseline.md`](Icod.LineEditor-LE0-Baseline.md). Because the inspected Red project contained only a placeholder entry point, LE0 also establishes the required public `Icod.LineEditor.Red.Command` facade while preserving that seed output; Phase LE8 remains responsible for actual restricted-editor behavior. -+ -+Phase LE1 is now complete. The monolithic Sed implementation has been decomposed into responsibility-focused partial-class modules while preserving the public command signatures and the pre-LE1 regex, record, script-source, sandbox, process, and replacement semantics. New characterization and module-boundary tests make those temporary behaviors explicit for the later semantic phases. -+ -+Phase LE2 is now complete. The Gate R1 BRE/ERE foundation has been revalidated as the LineEditor consumer boundary, with direct acceptance coverage for syntax profiles, leftmost-longest selection, captures, locale policy, string and exact-byte coordinates, malformed input, diagnostics, cancellation, and resource limits. The audit found no missing cross-suite production contract and leaves Sed-specific state and replacement policy for LE3. -+ -+Phase LE3 migrated Sed matching to the Shared managed GNU BRE/ERE providers while retaining command-owned empty-expression reuse, modifiers, replacement context, GNU escape preprocessing, and diagnostic presentation. -+ -+Phase LE4 established byte-preserving LF/NUL record framing, explicit termination, C/POSIX byte and UTF-8 locale profiles, invalid-byte preservation, and explicit data separators. -+ -+Phase LE5 is now complete. Sed's primary entry point consumes `CommandContext`; script expressions, files, and the implicit operand retain distinct identities and source-relative locations; shell, auxiliary-file, and in-place operations are injectable; Shared `ProcessRunner` remains the system shell implementation; sandbox restrictions have compile-time and runtime enforcement; and the provisional replacement boundary established for LE10 has now been migrated to Completion Gate E6. -+ -+Phase LE6 is now complete. `Icod.LineEditor.Ed.Shared` and its dedicated tests establish the mutable Ed/Red engine with bounded segmented line storage, stable identities, Ed-specific addresses, marks, cut buffers, substitutions, global commands, undo and remembered state, injected file/process capabilities, immutable standard/restricted profiles, Shared BRE and record/process/temporary/filesystem consumption, and textual GNU/Icod Diffutils ed-script fixtures. -+ -+Phase LE7 is now complete. The `ed` executable retains `Icod.LineEditor.Ed.Command` and the lowercase `ed` assembly while becoming a thin GNU ed 1.22.5 command/session host over the LE6 engine. It now owns declarative option parsing, byte-preserving `CommandContext` orchestration, initial file and `+line`/search selection, standard/restricted profile composition, prompting, diagnostics, script presentation, signal/cancellation mapping, and command-level scale/interoperability tests. -+ -+Phase LE8 is now complete. `red` retains `Icod.LineEditor.Red.Command` and the lowercase `red` assembly, uses the same Ed engine and immutable restricted profile as `ed --restricted`, denies shell operations before dispatch and again at the process-capability layer, applies host-independent restricted pathname classification, captures its working directory once, documents pathname restriction rather than physical confinement, and adds adversarial command and state-preservation tests. -+ -+Phase LE9 is now complete. The completed Sed and Ed implementations were compared rather than abstracted speculatively. Neutral regular-expression, record, diagnostic, process, temporary, filesystem, and text contracts remain in the current Shared incubation project; mutable Ed/Red behavior remains in `Icod.LineEditor.Ed.Shared`; Sed program and cycle behavior remains in `Icod.LineEditor.Sed`. No cohesive residual family library remains, so `Icod.LineEditor.Shared` is not created. The evidence and dependency decision are recorded in `Icod.LineEditor-LE9-Sharing-Audit.md` and locked by architecture-boundary tests. -+ -+Phase LE10 is now complete. Sed in-place editing and Ed complete-file writes consume Completion Gate E6 transactional replacement; command-owned backup, append, force, modified-buffer, and link policies remain above the shared mechanism. Atomic publication, rollback, metadata, cancellation, link, failure-injection, and cleanup coverage is recorded in `Icod.LineEditor-LE10-Transactional-Replacement.md`. Completion Gate F1 is now active. -+ -+--- -+ -+## Executive decision -+ -+The previous plan proposed creating both: -+ -+```text -+Icod.LineEditor.Shared -+Icod.LineEditor.Ed.Shared -+``` -+ -+before implementing Ed and Red and before refactoring Sed. -+ -+After examining the present repository, that is too eager. -+ -+The revised recommendation is: -+ -+1. **Do create `Icod.LineEditor.Ed.Shared`.** -+ Ed and Red are two security profiles over the same mutable line-editor engine. Their shared engine is already proven by the upstream relationship between the commands. -+ -+2. **Do not create `Icod.LineEditor.Shared` at the beginning.** -+ Most of the plausible cross-editor infrastructure already exists in the current `Icod.CoreUtils.Shared` incubation project and is more accurately classified as future `Icod.CommandFramework` material. -+ -+3. **Keep `Icod.LineEditor.Sed` as its own command and engine project.** -+ It already has the correct project name, root namespace, executable assembly name, public command class, asynchronous entry point, dedicated test project identity, and direct reference to the current Shared project. -+ -+4. **Refactor Sed internally before attempting to extract an Ed/Sed family library.** -+ The pre-LE1 Sed implementation was a large monolithic `Command.cs`. Phase LE1 has now made its internal boundaries visible without changing behavior. -+ -+5. **Implement `Icod.LineEditor.Ed.Shared`, then Ed and Red.** -+ After both the decomposed Sed engine and complete Ed engine exist, perform a consumer audit. -+ -+6. **Create `Icod.LineEditor.Shared` only if the audit finds meaningful line-editor-family behavior that:** -+ - is used by both Sed and the Ed family; -+ - is not already appropriate for `Icod.CommandFramework`; -+ - is not merely similar-looking syntax with different semantics; -+ - is substantial enough to justify another assembly and package boundary. -+ -+The revised architecture during incubation is therefore: -+ -+```text -+Current Icod.CoreUtils.Shared incubation project -+Γöé -+Γö£ΓöÇΓöÇ Icod.LineEditor.Sed -+Γöé -+ΓööΓöÇΓöÇ Icod.LineEditor.Ed.Shared -+ Γö£ΓöÇΓöÇ Icod.LineEditor.Ed -+ ΓööΓöÇΓöÇ Icod.LineEditor.Red -+``` -+ -+An optional future library may appear later: -+ -+```text -+Icod.LineEditor.Shared -+``` -+ -+but it is an outcome of the implementation audit, not a prerequisite. -+ -+--- -+ -+## Why the recommendation changed -+ -+### The present Shared project already owns the likely cross-editor foundations -+ -+The current `Icod.CoreUtils.Shared` project is no longer a small Coreutils helper assembly. It already contains focused areas for: -+ -+```text -+CommandLine -+Diagnostics -+Delimiters -+Escapes -+FileSystem -+IO -+Platform -+Processes -+Ranges -+Records -+RegularExpressions -+Temporary -+Text -+Time -+``` -+ -+Its README explicitly identifies the project as an incubation location and instructs commands to use: -+ -+- `CommandContext`; -+- `OptionParser`; -+- decoded or byte-preserving record readers as appropriate; -+- `ProcessRunner`; -+- `IRegularExpressionProvider`; -+- secure temporary-object infrastructure; -+- explicit text, locale, and display-width abstractions. -+ -+These facilities are useful not only to Coreutils and the line editors, but also to Grep, Diffutils, Patch, Tar, and ProcPs. They are therefore natural future `Icod.CommandFramework` candidates. -+ -+Creating `Icod.LineEditor.Shared` now and placing wrappers or copies of these facilities into it would produce the wrong dependency boundary: -+ -+```text -+Icod.LineEditor.Shared -+ Γö£ΓöÇΓöÇ a second regular-expression abstraction -+ Γö£ΓöÇΓöÇ a second record abstraction -+ Γö£ΓöÇΓöÇ a second process abstraction -+ ΓööΓöÇΓöÇ a second diagnostic abstraction -+``` -+ -+That would increase duplication immediately before the final framework audit is intended to eliminate it. -+ -+### The current Sed project has already completed the structural namespace move -+ -+The previous plan treated Sed separation as future work. That is no longer accurate. -+ -+The current project already has: -+ -+```xml -+sed -+Icod.LineEditor.Sed -+``` -+ -+and references: -+ -+```xml -+ -+``` -+ -+The command source is already: -+ -+```csharp -+namespace Icod.LineEditor.Sed; -+ -+public static class Command -+{ -+} -+``` -+ -+The test assembly and root namespace are already: -+ -+```text -+Icod.LineEditor.Sed.Tests -+``` -+ -+Therefore, the next Sed task is not ΓÇ£move Sed to its final namespace.ΓÇ¥ The remaining structural cleanup is narrower: -+ -+- rename the stale test project filename from `Icod.CoreUtils.Sed.Tests.csproj` to `Icod.LineEditor.Sed.Tests.csproj`; -+- update any stale solution-project display names or roadmap language; -+- optionally move physical directories under a `LineEditor` suite directory when the suite block is undertaken; -+- decompose the implementation internally. -+ -+### The current Sed engine is one monolithic source file -+ -+The present `sed/src` directory contains one source file, `Command.cs`, and that file is more than four thousand lines long. -+ -+It includes, as private nested types and private static methods: -+ -+- command options; -+- address types; -+- address ranges; -+- instruction types; -+- script parsing; -+- script diagnostics; -+- source specifications; -+- record reading; -+- input sequencing; -+- execution state; -+- pattern-space and hold-space behavior; -+- substitution; -+- regular-expression translation; -+- transliteration; -+- shell execution; -+- auxiliary file access; -+- in-place editing; -+- backup handling; -+- symlink handling; -+- command orchestration. -+ -+This is not evidence that these concerns belong together. It is evidence that the existing implementation has not yet exposed its real internal boundaries. -+ -+Creating a family Shared project before decomposing this file would encourage extracting code based on textual proximity rather than proven ownership. -+ -+### The current Shared project already has a GNU BRE engine, while Sed bypasses it -+ -+The current Shared regular-expression foundation is explicitly designed to avoid translating GNU basic regular expressions into `System.Text.RegularExpressions`. -+ -+It provides: -+ -+```text -+IRegularExpressionProvider -+ICompiledRegularExpression -+RegularExpressionCompileResult -+RegularExpressionMatchResult -+RegularExpressionDiagnostic -+RegularExpressionOptions -+``` -+ -+and implements GNU/POSIX basic regular-expression behavior with leftmost-longest matching and injectable locale/classification policy. -+ -+Before LE3, Sed contained private methods that translated common BRE syntax and selected POSIX character classes into `System.Text.RegularExpressions.Regex`. That was the clearest immediate example of code that should not move into `Icod.LineEditor.Shared`. -+ -+Completion Gate R1 supplied direct managed GNU Basic and Extended providers, and LE2 confirmed that their syntax, locale, capture, coordinate, diagnostic, cancellation, and resource contracts satisfy the LineEditor consumers. LE3 now consumes those providers through `SedRegularExpressionCompiler`; the private translator is removed. -+ -+### The Shared regular-expression API is sufficient for Sed selection mechanics -+ -+`IRegularExpressionProvider` now supports the GNU Basic and Extended profiles required by `-E`, `-r`, and `--regexp-extended`. Sed layers its own empty-expression reuse, address/substitution modifiers, GNU escape preprocessing, POSIX mode, occurrence selection, empty-match iteration, replacement expansion, and diagnostic presentation above the command-neutral Shared matcher. No line-editor-only regex library is required. -+ -+### The current Shared project has a better record model than Sed currently uses -+ -+The current Shared project has two relevant levels: -+ -+1. `Icod.CoreUtils.Shared.IO.DelimitedRecordReader` -+ - decoded `TextReader` records; -+ - returns `string`; -+ - may trim a carriage return preceding an LF; -+ - suitable only when exact source bytes and final-termination state are not part of the command contract. -+ -+2. `Icod.CoreUtils.Shared.Records` -+ - byte-preserving LF or NUL records; -+ - explicit termination state; -+ - segmented bounded reading for enormous records; -+ - separate record content and separator writing. -+ -+The current Sed implementation wraps the decoded `DelimitedRecordReader`, selecting LF or NUL and enabling carriage-return trimming for LF input. -+ -+That is convenient, but it conflicts with the repository's newer byte-preserving text policy: -+ -+- LF is a data separator; -+- NUL is a data separator under `-z`; -+- CR is ordinary data unless a command option explicitly strips it; -+- an unterminated final record is semantically different from a terminated record; -+- invalid encoded bytes cannot be silently normalized through a `TextReader`. -+ -+This does not mean the entire Sed engine must immediately become a byte-array interpreter. It does mean the authoritative input and output model should eventually preserve bytes and terminator state. -+ -+### The current Sed process reuse is directionally correct -+ -+Sed already uses the shared `ProcessRunner` for shell execution instead of duplicating child-process redirection and cancellation. -+ -+That is good reuse and should be preserved. -+ -+The weakness is that shell execution is still reached through a private static method. Sed sandbox mode is enforced primarily during parsing by rejecting file and execution commands. -+ -+The stronger design is: -+ -+```text -+parser-level prohibition -+ + -+runtime capability denial -+``` -+ -+A denied shell executor should remain incapable of launching a process even if a future parser or nested command path accidentally reaches it. -+ -+This facade is Sed-specific policy over a shared process mechanism; it is not a replacement for the shared process mechanism. -+ -+### The current in-place replacement is not yet the final transaction model -+ -+The current Sed implementation already has several worthwhile properties: -+ -+- it creates the temporary output with `FileMode.CreateNew`; -+- it attempts cleanup after failure; -+- it retains Unix mode where available; -+- it supports backup suffixes; -+- it has tests for backups, modes, and symlink-following behavior. -+ -+However, the replacement sequence can: -+ -+1. delete or move the original; -+2. then move the temporary file into place. -+ -+There is no use of `File.Replace`, and the operation is not yet expressed through the shared transactional replacement model planned for Completion Gate E6. -+ -+A failure between removal of the original and installation of the temporary output can therefore produce a data-loss or recovery problem. -+ -+The correct architectural response is not to create a Sed-only transaction engine. Sed should isolate in-place editing behind an internal boundary now and consume the shared E6 replacement contract when that gate is complete. -+ -+--- -+ -+## Current repository assessment -+ -+## `Icod.CoreUtils.Shared` -+ -+### Strengths -+ -+The current Shared project is already a strong incubation foundation. -+ -+It provides: -+ -+- a declarative GNU/POSIX-style option parser; -+- command contexts and standard diagnostics; -+- byte-preserving text units and logical lines; -+- explicit locale and display-width providers; -+- LF/NUL record framing; -+- exact final-record termination metadata; -+- GNU range parsing; -+- delimiter and escape scanning; -+- a managed GNU BRE/ERE implementation; -+- shell-free child-process execution; -+- secure temporary workspaces; -+- platform capability reporting. -+ -+This makes it the correct temporary home for code that will eventually become `Icod.CommandFramework`. -+ -+### Architectural risk -+ -+The project name still says `Icod.CoreUtils.Shared`, but its contents now span three categories: -+ -+```text -+future Icod.CommandFramework -+future Icod.CoreUtils.Shared -+possibly future Icod.FileUtils.Shared or Icod.TextUtils.Shared -+``` -+ -+The immediate danger is not that LineEditor lacks a shared project. The danger is that another shared project might be created to duplicate APIs already incubating here. -+ -+Every LineEditor proposal should therefore be classified before implementation: -+ -+```text -+Cross-suite framework candidate -+LineEditor-family-specific -+Ed-family-specific -+Sed-specific -+Command-local -+``` -+ -+### Shared enhancements completed for the LineEditor work -+ -+The most important Shared work was not a LineEditor package. Completion Gate R1 extended the existing cross-suite contract, and Phase LE2 has now verified that contract against the pinned LineEditor baselines without requiring another production API. -+ -+#### GNU ERE support -+ -+The regular-expression API now explicitly compiles: -+ -+```text -+GNU/POSIX basic regular expressions -+GNU/POSIX extended regular expressions -+``` -+ -+A compatible shape might be: -+ -+```csharp -+public enum RegularExpressionSyntax -+{ -+ Basic, -+ Extended, -+} -+``` -+ -+with syntax selected through `RegularExpressionOptions`: -+ -+```csharp -+public sealed record RegularExpressionOptions -+{ -+ public RegularExpressionSyntax Syntax { get; init; } -+ = RegularExpressionSyntax.Basic; -+} -+``` -+ -+The existing `Compile(pattern, options, token)` methods can remain source-compatible because Basic remains the default. -+ -+The implementation must not translate ERE to .NET regex syntax as its conformance mechanism. The managed parser and matcher should understand the selected GNU/POSIX syntax profile directly. -+ -+Consumers would include: -+ -+```text -+expr BRE -+ed BRE -+sed BRE and ERE -+grep BRE and ERE -+csplit BRE -+``` -+ -+#### Byte-preserving regex integration -+ -+Eventually, regex matching needs a deliberate relationship with the Shared text-unit and byte-record models. -+ -+At minimum, the design must state: -+ -+- whether matching occurs over raw bytes or decoded scalars; -+- how C/POSIX locale differs from UTF-8 locale; -+- how match offsets map back to authoritative source bytes; -+- how replacement output is encoded; -+- how invalid sequences are preserved or rejected; -+- how NUL-delimited input interacts with matching; -+- how line-sensitive anchors are interpreted. -+ -+This is cross-suite infrastructure because Grep, Sed, Ed, Expr, and Csplit all depend on it. -+ -+#### Injectable process and filesystem capabilities -+ -+The existing static helpers should gradually be surfaced through injectable providers or factories where security profiles require denial or deterministic tests. -+ -+LineEditor consumers need: -+ -+```text -+process execution -+file opening -+auxiliary file writes -+temporary files -+transactional replacement -+path and symlink inspection -+``` -+ -+The general mechanisms belong in the current Shared incubation project. Sed and Red apply their own policy profiles over those mechanisms. -+ -+--- -+ -+## `Icod.LineEditor.Sed` -+ -+### What is already good -+ -+The current Sed seed is substantially more than a placeholder. -+ -+It already has: -+ -+- the correct namespace and project identity; -+- a public `Icod.LineEditor.Sed.Command`; -+- C# 13 and `net10.0`; -+- an asynchronous `Main`; -+- cancellation handling; -+- a `CommandContext` at the executable boundary; -+- Shared `OptionParser` use; -+- Shared diagnostics; -+- Shared `ProcessRunner` use; -+- streaming one-record lookahead rather than whole-input buffering; -+- support for LF and NUL record modes; -+- broad command coverage; -+- basic and extended regular-expression options; -+- pattern and hold spaces; -+- branching; -+- grouped commands; -+- substitutions and transliteration; -+- file read and write commands; -+- sandbox mode; -+- POSIX mode; -+- debug output; -+- in-place editing; -+- backup suffixes; -+- symlink-following behavior; -+- a useful 483-line functional test suite. -+ -+These tests are valuable characterization assets and should be retained before structural changes. -+ -+### What should change -+ -+#### `Command.cs` should become an orchestration boundary -+ -+The exact public class remains: -+ -+```text -+Icod.LineEditor.Sed.Command -+``` -+ -+but it should no longer contain the entire interpreter. -+ -+The public class should be responsible for: -+ -+- compatibility `Run` overloads; -+- cancellation-aware `RunAsync` overloads; -+- option parsing orchestration; -+- script-source assembly; -+- service composition; -+- invoking the compiler and executor; -+- mapping controlled failures to diagnostics and exit statuses. -+ -+It should not itself define every address, instruction, parser, record reader, executor, regex translator, file transaction, and shell adapter. -+ -+#### `CommandContext` should flow into the command core -+ -+The current `Program` creates a `CommandContext`, then immediately breaks it back into four arguments: -+ -+```text -+StandardInput -+StandardOutput -+StandardError -+CancellationToken -+``` -+ -+Add a core overload such as: -+ -+```csharp -+public static Task RunAsync( -+ string[] args, -+ CommandContext context -+) -+``` -+ -+Keep the existing stream-based overloads as compatibility and test conveniences. -+ -+Internally, the core execution path should retain the context rather than reconstruct program-name and diagnostic behavior manually. -+ -+#### Script sources should remain distinct -+ -+The current implementation joins all `-e` and `-f` script fragments using `Environment.NewLine`. -+ -+That loses source identity and makes parsing depend on a host-generated line separator even though script separators are part of Sed grammar. -+ -+Represent script sources explicitly: -+ -+```text -+command-line expression -+script file -+implicit first operand -+``` -+ -+Each source should retain: -+ -+- source name; -+- source kind; -+- original text or byte content; -+- starting line and column; -+- whether a synthetic separator is required between sources. -+ -+The compiler may consume a composite source stream, but diagnostics should still identify the original source. -+ -+A synthetic separator inserted between script sources is Sed grammar data and should be an explicit LF or parser token, not `Environment.NewLine`. -+ -+#### Replace the private regex translator -+ -+This is the highest-value semantic refactor. -+ -+Introduce a Sed-specific adapter over the shared provider: -+ -+```text -+SedRegularExpressionCompiler -+ Γåô -+IRegularExpressionProvider -+``` -+ -+The adapter owns Sed policy: -+ -+- BRE versus ERE selection; -+- empty-pattern reuse; -+- address versus substitution context; -+- GNU and POSIX mode selection; -+- substitution match iteration; -+- Sed-specific diagnostics. -+ -+It does not own a second regex engine. -+ -+Delete the private BRE-to-.NET and POSIX-class translation code only after equivalent tests pass through the shared provider. -+ -+#### Preserve bytes and record termination -+ -+Introduce an internal Sed input model such as: -+ -+```text -+SedInputRecord -+SedRecordSeparator -+SedInputPosition -+``` -+ -+It should retain: -+ -+- authoritative record bytes; -+- whether the source record was terminated; -+- the separator used; -+- source-file identity; -+- per-file and aggregate record number; -+- optional decoded text and byte-to-text mapping. -+ -+Do not claim that Sed has bounded memory merely because its input reader is segmented. Sed commands such as `N`, `G`, substitutions, and repeated branching can legitimately grow pattern or hold space. -+ -+The meaningful invariant is: -+ -+> Sed streams the input and does not retain unrelated completed input records, while the current pattern and hold spaces may grow according to Sed semantics. -+ -+#### Treat LF and NUL as data semantics -+ -+For Sed: -+ -+```text -+LF in normal mode -+NUL under -z -+``` -+ -+are command data, not host presentation line endings. -+ -+Therefore: -+ -+- do not use `Environment.NewLine` to serialize Sed output records; -+- do not trim CR automatically merely because the host is Windows; -+- preserve an unterminated final record; -+- add explicit tests for CRLF input, lone CR, invalid UTF-8, NUL records, and incomplete final records. -+ -+The general repository rule allowing `Environment.NewLine` for host-generated messages does not apply to Sed's transformed data stream. -+ -+Diagnostics may use host line endings. Sed output data must follow Sed semantics. -+ -+#### Separate compiler state from execution state -+ -+A proposed internal layout is: -+ -+```text -+sed/src/ -+Γö£ΓöÇΓöÇ README.md -+Γö£ΓöÇΓöÇ Command.cs -+Γöé -+Γö£ΓöÇΓöÇ Options/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé ΓööΓöÇΓöÇ SedOptions.cs -+Γöé -+Γö£ΓöÇΓöÇ Scripting/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé Γö£ΓöÇΓöÇ ScriptSource.cs -+Γöé Γö£ΓöÇΓöÇ ScriptSourceMap.cs -+Γöé Γö£ΓöÇΓöÇ ScriptParser.cs -+Γöé Γö£ΓöÇΓöÇ SedProgram.cs -+Γöé Γö£ΓöÇΓöÇ Instruction.cs -+Γöé Γö£ΓöÇΓöÇ InstructionKind.cs -+Γöé Γö£ΓöÇΓöÇ ScriptDiagnostic.cs -+Γöé ΓööΓöÇΓöÇ ScriptParseException.cs -+Γöé -+Γö£ΓöÇΓöÇ Addresses/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé Γö£ΓöÇΓöÇ SedAddress.cs -+Γöé Γö£ΓöÇΓöÇ SedAddressRange.cs -+Γöé Γö£ΓöÇΓöÇ AddressSelectionState.cs -+Γöé ΓööΓöÇΓöÇ AddressContext.cs -+Γöé -+Γö£ΓöÇΓöÇ Execution/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé Γö£ΓöÇΓöÇ SedExecutor.cs -+Γöé Γö£ΓöÇΓöÇ SedExecutionState.cs -+Γöé Γö£ΓöÇΓöÇ PatternSpace.cs -+Γöé Γö£ΓöÇΓöÇ HoldSpace.cs -+Γöé Γö£ΓöÇΓöÇ DeferredOutputQueue.cs -+Γöé ΓööΓöÇΓöÇ SedInputSequence.cs -+Γöé -+Γö£ΓöÇΓöÇ Records/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé Γö£ΓöÇΓöÇ SedInputRecord.cs -+Γöé Γö£ΓöÇΓöÇ SedRecordReader.cs -+Γöé ΓööΓöÇΓöÇ SedRecordWriter.cs -+Γöé -+Γö£ΓöÇΓöÇ RegularExpressions/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé ΓööΓöÇΓöÇ SedRegularExpressionCompiler.cs -+Γöé -+Γö£ΓöÇΓöÇ Substitution/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé Γö£ΓöÇΓöÇ SubstitutionCommand.cs -+Γöé Γö£ΓöÇΓöÇ SubstitutionFlags.cs -+Γöé Γö£ΓöÇΓöÇ ReplacementTemplate.cs -+Γöé ΓööΓöÇΓöÇ SedSubstitutionEngine.cs -+Γöé -+Γö£ΓöÇΓöÇ Files/ -+Γöé Γö£ΓöÇΓöÇ README.md -+Γöé Γö£ΓöÇΓöÇ AuxiliaryFileManager.cs -+Γöé Γö£ΓöÇΓöÇ InPlaceEditor.cs -+Γöé ΓööΓöÇΓöÇ BackupNamePolicy.cs -+Γöé -+ΓööΓöÇΓöÇ Processes/ -+ Γö£ΓöÇΓöÇ README.md -+ Γö£ΓöÇΓöÇ ISedShellExecutor.cs -+ Γö£ΓöÇΓöÇ ProcessRunnerShellExecutor.cs -+ ΓööΓöÇΓöÇ DeniedShellExecutor.cs -+``` -+ -+The exact file split can change, but the responsibilities should be separate. -+ -+Under the repository convention, every directory containing more than one source file receives a substantive `README.md`, and all internal types and members receive substantive XML documentation. -+ -+#### Keep most types internal -+ -+The public contract should remain deliberately small: -+ -+```text -+Icod.LineEditor.Sed.Command -+``` -+ -+Supporting parser, program, address, execution, substitution, and transaction types should remain `internal` unless an external consumer is demonstrated. -+ -+The dedicated tests may use `InternalsVisibleTo` where focused engine tests are preferable to command-line-only tests. -+ -+#### Add defense in depth to sandbox mode -+ -+Retain parser-level rejection so invalid sandbox scripts fail before processing input. -+ -+Also compose execution with denied capabilities: -+ -+```text -+ISedShellExecutor -+ISedAuxiliaryFileAccess -+ISedInPlaceEditAccess -+``` -+ -+In sandbox mode, denied implementations should reject access even if an instruction somehow reaches runtime execution. -+ -+Do not reuse Red's complete security policy as Sed's sandbox policy. They have different rules: -+ -+- Red permits files in the current directory but denies directories and shell commands; -+- Sed sandbox mode denies input, output, and external-command operations defined by GNU Sed policy. -+ -+They may consume the same lower-level shared process and filesystem abstractions without sharing one policy object. -+ -+#### Isolate in-place editing now; replace its mechanics at E6 -+ -+Create an internal `InPlaceEditor` boundary before changing behavior. -+ -+The first refactor can preserve current behavior behind that boundary, with characterization tests. -+ -+When Completion Gate E6 is implemented, replace the internals with shared: -+ -+- secure sibling temporary files; -+- atomic replacement where supported; -+- backup-name policy; -+- rollback; -+- metadata preservation; -+- symlink and reparse-point policy; -+- deterministic cleanup; -+- explicit capability diagnostics. -+ -+Add failure-injection tests for every transition: -+ -+```text -+temporary creation -+input read -+output write -+flush -+backup creation -+metadata capture -+original replacement -+metadata restoration -+cleanup -+``` -+ -+--- -+ -+## Revised Ed and Red architecture -+ -+## `Icod.LineEditor.Ed.Shared` is still required -+ -+Unlike the speculative Ed/Sed family library, the Ed/Red shared engine is certain. -+ -+GNU Red is restricted Ed. Both commands share: -+ -+- command-line interpretation; -+- mutable line buffer; -+- Ed addresses and ranges; -+- current address; -+- marks; -+- cut buffer; -+- insert, append, change, delete, move, copy, join, yank, and put; -+- printing and listing; -+- substitutions; -+- global and inverse-global commands; -+- undo; -+- modified state; -+- remembered filename; -+- file commands; -+- shell and filter command plumbing; -+- signals and cancellation; -+- diagnostic and exit-status rules. -+ -+The only meaningful difference is the selected security profile and command identity. -+ -+The correct projects are: -+ -+```text -+Icod.LineEditor.Ed.Shared -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+``` -+ -+with public command classes: -+ -+```text -+Icod.LineEditor.Ed.Command -+Icod.LineEditor.Red.Command -+``` -+ -+### The present Ed code is a seed, not an engine -+ -+The current Ed command is a short synchronous implementation using: -+ -+- `List`; -+- direct `File.ReadAllLines`; -+- direct `File.WriteAllLines`; -+- .NET regular expressions; -+- a small subset of commands; -+- no complete address model; -+- no complete state machine; -+- no Red security profile. -+ -+It should be treated as a historical seed and source of a few tests, not as the architecture to be extracted. -+ -+### The present Red project is only a shell -+ -+The current Red project has the correct assembly and namespace identity but no implemented shared editor engine. -+ -+This is useful: there is little compatibility burden preventing the correct architecture from being established. -+ -+### Ed/Red dependency structure -+ -+During incubation: -+ -+```text -+Current Shared incubation project -+ Γåô -+Icod.LineEditor.Ed.Shared -+ Γåô -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+``` -+ -+Projects should explicitly reference the narrowest assemblies whose APIs they directly use. Do not depend on accidental transitive references. -+ -+The command projects should be thin: -+ -+```text -+parse process-level invocation -+select command identity -+select standard or restricted security profile -+invoke shared Ed application -+return exit status -+``` -+ -+### Red security remains an Ed-specific policy -+ -+`Icod.LineEditor.Ed.Shared` should contain: -+ -+```text -+EditorSecurityPolicy -+IEditorFileAccess -+IEditorProcessAccess -+StandardEditorFileAccess -+RestrictedEditorFileAccess -+StandardEditorProcessAccess -+DeniedEditorProcessAccess -+``` -+ -+Both: -+ -+```text -+red -+ed --restricted -+``` -+ -+must select the same immutable policy and engine path. -+ -+Red restrictions require defense in depth: -+ -+- parser or dispatcher rejects shell-bearing command forms; -+- denied process capability cannot execute a process; -+- every filename-bearing operation uses the restricted file capability; -+- the current directory is captured once; -+- Unix and Windows path forms are considered; -+- symlink, hard-link, reparse-point, and validation/open race behavior is documented and tested; -+- Red is not described as a complete hostile-code sandbox unless its actual confinement guarantees support that claim. -+ -+--- -+ -+## Why a general `Icod.LineEditor.Shared` project is now optional -+ -+After the repository audit, the likely Ed/Sed overlap falls into three categories. -+ -+### Category 1 ΓÇö already cross-suite -+ -+These belong in the current Shared incubation project and later `Icod.CommandFramework`: -+ -+```text -+command contexts -+option processing -+diagnostics -+record framing -+text decoding -+locale providers -+regular-expression engine -+process execution -+temporary workspaces -+filesystem capabilities -+transactional replacement -+``` -+ -+### Category 2 ΓÇö similar spelling but different semantics -+ -+These must remain separate: -+ -+```text -+Ed addresses versus Sed addresses -+Ed global commands versus Sed branch programs -+Ed mutable buffer versus Sed pattern space -+Ed undo versus Sed cycle state -+Ed file-modified state versus Sed in-place transactions -+Red restrictions versus Sed sandbox mode -+``` -+ -+### Category 3 ΓÇö audited family candidates with no cohesive residual -+ -+Possible examples include: -+ -+- delimiter-aware scanning of editing expressions; -+- source spans for command scripts; -+- replacement-template lexical tokens; -+- shared command-script diagnostic formatting; -+- common adapters from the shared regex engine into substitution commands. -+ -+Phase LE9 found that these remain private or internal implementations in Sed and Ed because their grammar, source-location, diagnostic, and mutation contracts do not align. -+ -+Phase LE9 completed that comparison: -+ -+1. the implementations were compared; -+2. their grammar and error semantics were found to differ materially; -+3. neutral contracts were confirmed in the current Shared incubation project; -+4. Ed-family and Sed-specific implementations were retained with their consumers; -+5. no cohesive residual justified `Icod.LineEditor.Shared`. -+ -+It is entirely acceptable for the final architecture to have no `Icod.LineEditor.Shared` project: -+ -+```text -+Icod.CommandFramework -+Γö£ΓöÇΓöÇ Icod.LineEditor.Ed.Shared -+Γöé Γö£ΓöÇΓöÇ Icod.LineEditor.Ed -+Γöé ΓööΓöÇΓöÇ Icod.LineEditor.Red -+ΓööΓöÇΓöÇ Icod.LineEditor.Sed -+``` -+ -+That may be cleaner than creating a package containing only thin wrappers over framework APIs. -+ -+--- -+ -+## Revised implementation sequence -+ -+The current roadmap records Batches 0 through 20 as complete and places the LineEditor milestones after the consecutive Diffutils block and the Patch milestone. This plan does not require moving the LineEditor work earlier. -+ -+When the LineEditor milestone is undertaken, use the following sequence. -+ -+## Phase LE0 ΓÇö Correct documentation and project-policy drift -+ -+- [x] Replace stale roadmap references to `Icod.Ed.Shared`, `Icod.Ed.Ed`, and `Icod.Ed.Red`. -+- [x] Use `Icod.LineEditor.Ed.Shared`, `Icod.LineEditor.Ed`, and `Icod.LineEditor.Red`. -+- [x] Replace stale roadmap references to project `Icod.Sed` with `Icod.LineEditor.Sed`. -+- [x] Assign the Ed engine to `Icod.LineEditor.Ed.Shared`, not `Icod.LineEditor.Shared`. -+- [x] Keep `Icod.LineEditor.Shared` explicitly optional and evidence-based rather than part of the required initial project list. -+- [x] Rename `Icod.CoreUtils.Sed.Tests.csproj` to `Icod.LineEditor.Sed.Tests.csproj`. -+- [x] Confirm matching solution-project names and retain all test projects under the centralized `tests` solution folder. -+- [x] Add `13.0` to the current Ed and Red projects. -+- [x] Remove the UTF-8 BOM from the Red project file to follow repository text conventions. -+- [x] Record GNU sed 4.10 and GNU ed 1.22.5 in the authoritative ledger. -+- [x] Capture the current full solution and Sed test baseline before refactoring in [`Icod.LineEditor-LE0-Baseline.md`](Icod.LineEditor-LE0-Baseline.md). -+ -+LE0 is complete. The historical ΓÇ£Change/ReplaceΓÇ¥ examples later in this document remain as rationale showing what was corrected; they are not active project names or ownership policy. -+ -+## Phase LE1 ΓÇö Characterize and decompose the current Sed implementation -+ -+- [x] Add missing characterization tests before moving private types. -+- [x] Split options, parser, program model, addresses, execution, records, substitution, files, and processes into focused internal modules. -+- [x] Keep public behavior and `Icod.LineEditor.Sed.Command` signatures stable. -+- [x] Add directory `README.md` files and XML documentation as required. -+- [x] Add focused internal tests without deleting the command-level tests. -+- [x] Keep the current regex and record behavior temporarily so the structural refactor remains reviewable. -+ -+Phase LE1 is complete and behavior-preserving. `Command.cs` now contains only the public facade and orchestration path, while nine partial-class modules make the existing private responsibilities explicit. Characterization tests freeze the current option, script-source, diagnostic, record, sandbox, and in-place-edit behavior until their scheduled semantic phases. -+ -+## Phase LE2 ΓÇö Extend the current Shared regex foundation -+ -+- [x] Add an explicit Basic-versus-Extended syntax profile. -+- [x] Implement GNU/POSIX ERE in the managed parser and matcher. -+- [x] Preserve existing BRE callers and default behavior. -+- [x] Add leftmost-longest, capture, repetition, alternation, bracket, locale, cancellation, and diagnostic tests for ERE. -+- [x] Update the Shared regular-expression README to identify Sed and Grep as consumers. -+- [x] Define byte/text mapping requirements for future byte-preserving matches. -+ -+This phase belongs in Shared because it is cross-suite infrastructure. Completion Gate R1 delivered the production foundation before Batch 26; Phase LE2 has now revalidated it against the pinned GNU Sed and GNU Ed baselines. The LineEditor acceptance suite covers syntax, ERE composition, leftmost-longest selection, captures, locale policy, string and byte coordinates, invalid input, diagnostics, cancellation, and resource limits. No production Shared extension was required for the LF-oriented evidence available during LE2. LE4 later supplied concrete `-z` multiline evidence for a narrow command-neutral separator option; the follow-up is recorded in `Icod.LineEditor-LE2-Regex-Contract-Audit.md`. -+ -+## Phase LE3 ΓÇö Migrate Sed to the shared regex provider -+ -+- [x] Introduce `SedRegularExpressionCompiler`. -+- [x] Preserve Sed's empty-pattern reuse, GNU escape preprocessing, and command-context semantics. -+- [x] Route both address and substitution regex compilation through the shared provider. -+- [x] Remove the private .NET regex translator only after equivalence tests pass. -+- [x] Add GNU Sed differential tests for BRE and ERE. -+- [x] Add locale and leftmost-longest cases that .NET regex translation handled incorrectly. -+ -+Phase LE3 is complete. The Sed adapter now consumes the Shared managed GNU provider without moving Sed state into Shared. It retains the exact last compiled expression across address and substitution contexts, owns `I`/`M` modifiers, GNU escape preprocessing, POSIX-mode interpretation, controlled diagnostic presentation, and GNU zero-length global-substitution progression. The previous `System.Text.RegularExpressions` translation path is gone, and the migration suite includes GNU sed 4.10 cases for BRE, ERE, captures, locale classes, empty-expression reuse, multiline anchors, control and numeric escapes, strict-POSIX bracket behavior, repeated empty matches, and leftmost-longest selection. Phase LE4 has now completed the byte-preserving record and text migration. -+ -+## Phase LE4 ΓÇö Correct Sed record and text semantics -+ -+- [x] Introduce byte-preserving `SedInputRecord`. -+- [x] Use Shared record framing for LF and NUL modes. -+- [x] Preserve CR as data. -+- [x] Preserve explicit final-record termination. -+- [x] Define C/POSIX byte-locale matching and UTF-8 decoding behavior. -+- [x] Preserve invalid source bytes according to the selected profile. -+- [x] Write output separators explicitly as Sed data. -+- [x] Add CRLF, lone CR, invalid UTF-8, NUL, huge-record, and unterminated-record tests. -+- [x] Document that current pattern and hold spaces may grow according to Sed semantics. -+ -+Phase LE4 is complete as a semantic change separate from the LE1 decomposition. The CLI now consumes raw byte streams, LF and NUL framing comes from Shared records, CR remains data, final termination is explicit, malformed UTF-8 is preserved deterministically, and output separators are never selected from the host newline. Internal pattern/hold-space line operations select LF or NUL consistently, and Shared line-sensitive regex matching now accepts a caller-selected logical separator plus explicit NUL-dot policy for `-z`. The detailed contract and test matrix are recorded in `Icod.LineEditor-LE4-Record-and-Text-Semantics.md`; that contract is retained by the now-complete Phase LE5 orchestration boundary. -+ -+## Phase LE5 ΓÇö Harden Sed capabilities -+ -+- [x] Add a `CommandContext` core overload. -+- [x] Introduce injectable shell and external-file capabilities. -+- [x] Enforce sandbox restrictions at compile and runtime layers. -+- [x] Preserve Shared `ProcessRunner` rather than replacing it. -+- [x] Isolate in-place editing behind `InPlaceEditor`. -+- [x] Add failure-injection and cleanup tests. -+- [x] Defer final atomic replacement internals until Completion Gate E6. -+- [x] Remove `Environment.NewLine` from Sed data serialization and script-source joining. -+ -+Phase LE5 is complete. The detailed orchestration, script-source, sandbox, capability, and provisional in-place-edit contracts are recorded in `Icod.LineEditor-LE5-Orchestration-and-Capabilities.md`. Phases LE6 through LE10 are complete and Completion Gate F1 is active. -+ -+## Phase LE6 ΓÇö Create `Icod.LineEditor.Ed.Shared` -+ -+- [x] Create the library and dedicated test project. -+- [x] Design the mutable buffer, line identity, marks, undo, and editor state. -+- [x] Implement Ed addresses and command parsing independently from Sed addresses. -+- [x] Consume the shared BRE provider. -+- [x] Consume Shared records, process, temporary, and filesystem contracts. -+- [x] Define file and process security capabilities. -+- [x] Add textual compatibility fixtures for Ed scripts emitted by GNU Diffutils and `Icod.DiffUtils`. -+ -+Phase LE6 is complete. The reusable engine, security/capability boundary, Shared-contract consumption, compatibility fixtures, and LE7/LE8 migration boundary are documented in `Icod.LineEditor-LE6-Ed-Shared-Engine.md`. -+ -+## Phase LE7 ΓÇö Rebuild `Icod.LineEditor.Ed` -+ -+- [x] Retain `Icod.LineEditor.Ed.Command`. -+- [x] Replace the current seed internals with the shared Ed engine. -+- [x] Implement the standard security profile. -+- [x] Add GNU Ed conformance tests. -+- [x] Keep the executable assembly name `ed`. -+ -+Phase LE7 is complete. The command boundary, option and session policy, standard/restricted composition, byte-stream contract, exit statuses, and command-level validation matrix are documented in `Icod.LineEditor-LE7-Ed-Command.md`. -+ -+## Phase LE8 ΓÇö Implement `Icod.LineEditor.Red` -+ -+- [x] Retain `Icod.LineEditor.Red.Command`. -+- [x] Use the same Ed engine. -+- [x] Select the restricted security profile. -+- [x] Make `red` and `ed --restricted` equivalent. -+- [x] Add shell, path, filename-state, link, race, and platform-path adversarial tests. -+- [x] Keep the executable assembly name `red`. -+ -+Phase LE8 is complete. The permanent restricted command, shared immutable profile, parser/dispatcher and process-capability defenses, host-independent pathname policy, captured-directory and pathname-only confinement contract, and adversarial validation matrix are recorded in `Icod.LineEditor-LE8-Red-Restricted-Profile.md`. -+ -+## Phase LE9 ΓÇö Perform the actual LineEditor sharing audit -+ -+- [x] Compare Sed and Ed parser primitives. -+- [x] Compare replacement-template grammars and diagnostics. -+- [x] Separate cross-suite candidates from editor-family candidates. -+- [x] Confirm cross-suite code in the current Shared incubation project; no additional production move was required. -+- [x] Keep Ed-only code in `Icod.LineEditor.Ed.Shared`. -+- [x] Keep Sed-only code in `Icod.LineEditor.Sed`. -+- [x] Create `Icod.LineEditor.Shared` only if a cohesive residual library remains; the audit found none and does not create the project. -+- [x] Record consumer evidence and dependency direction for every retained or previously moved API. -+ -+Phase LE9 is complete. Ed and Sed delimiter parsing, address state, replacement templates, diagnostics, mutation models, and security policies were compared in detail. Similar-looking syntax did not form a stable family contract. Existing neutral contracts remain in the Shared incubation project, Ed/Red state remains in `Icod.LineEditor.Ed.Shared`, and Sed state remains in `Icod.LineEditor.Sed`. `Icod.LineEditor.Shared` is therefore not created. The classification matrix, consumer evidence, dependency graph, and reopening criteria are recorded in `Icod.LineEditor-LE9-Sharing-Audit.md`; architecture-boundary tests enforce the result. -+ -+## Phase LE10 ΓÇö Integrate the later filesystem transaction gate -+ -+After Completion Gate E6: -+ -+- [x] migrate Sed in-place editing to the shared transaction model; -+- [x] migrate Ed write/replacement operations where applicable; -+- [x] preserve command-specific backup and write policies; -+- [x] add atomicity, rollback, metadata, symlink, and cleanup tests; -+- [x] remove temporary command-local replacement mechanisms. -+ -+Phase LE10 is complete. Sed maps each in-place input to one E6 recovery unit, retains requested explicit backups, restores pre-existing backups during rollback, resolves `--follow-symlinks` before no-follow planning, and rejects unsupported terminal indirection without a nontransactional fallback. Ed stages whole-file writes and creations through E6, resolves terminal symbolic-link targets, preserves representable metadata, and retains direct append semantics. Both integrations use authoritative observations, stable-identity preconditions, durable secure sibling staging, structured transaction diagnostics, cancellation rollback, and deterministic cleanup. The implementation and validation matrix are recorded in `Icod.LineEditor-LE10-Transactional-Replacement.md`; Completion Gate F1 is now active. -+ -+--- -+ -+## Recommended final namespace and project structure -+ -+## Required projects -+ -+```text -+Icod.LineEditor.Ed.Shared -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+Icod.LineEditor.Sed -+``` -+ -+## Required public command classes -+ -+```text -+Icod.LineEditor.Ed.Command -+Icod.LineEditor.Red.Command -+Icod.LineEditor.Sed.Command -+``` -+ -+## Optional project -+ -+```text -+Icod.LineEditor.Shared -+``` -+ -+Phase LE9 found no cohesive residual library and therefore did not create it. Add it only if later completed consumers provide new evidence that reopens the audit. -+ -+## Likely namespaces -+ -+### Ed shared engine -+ -+```text -+Icod.LineEditor.Ed -+Icod.LineEditor.Ed.Addresses -+Icod.LineEditor.Ed.Buffering -+Icod.LineEditor.Ed.Commands -+Icod.LineEditor.Ed.Files -+Icod.LineEditor.Ed.Parsing -+Icod.LineEditor.Ed.Processes -+Icod.LineEditor.Ed.Security -+Icod.LineEditor.Ed.State -+Icod.LineEditor.Ed.Undo -+``` -+ -+### Sed engine -+ -+```text -+Icod.LineEditor.Sed -+Icod.LineEditor.Sed.Addresses -+Icod.LineEditor.Sed.Execution -+Icod.LineEditor.Sed.Files -+Icod.LineEditor.Sed.Options -+Icod.LineEditor.Sed.Processes -+Icod.LineEditor.Sed.Records -+Icod.LineEditor.Sed.RegularExpressions -+Icod.LineEditor.Sed.Scripting -+Icod.LineEditor.Sed.Substitution -+``` -+ -+A project name ending in `.Shared` does not require namespaces ending in `.Shared`. -+ -+--- -+ -+## Recommended roadmap corrections -+ -+The current roadmap has already adopted the `Icod.LineEditor` namespace family in several places, but older names remain. -+ -+The following corrections are recommended. -+ -+### Development architecture -+ -+Change: -+ -+```text -+Icod.Ed.Shared -+``` -+ -+to: -+ -+```text -+Icod.LineEditor.Ed.Shared -+``` -+ -+in the suite-specific Shared library lists and ultimate architecture examples. -+ -+### Temporary project inventory -+ -+Change the required LineEditor inventory from: -+ -+```text -+Icod.LineEditor.Shared -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+Icod.LineEditor.Sed -+``` -+ -+to: -+ -+```text -+Icod.LineEditor.Ed.Shared -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+Icod.LineEditor.Sed -+optional Icod.LineEditor.Shared after a consumer audit -+``` -+ -+### Suite-specific ownership -+ -+Replace the current statement that `Icod.LineEditor.Shared` owns the Ed engine with: -+ -+```text -+Icod.LineEditor.Ed.Shared owns Ed/Red address parsing, command parsing, -+mutable line buffers, marks, substitutions, global commands, undo, -+file operations, shell integration, and restricted-mode enforcement. -+``` -+ -+Retain: -+ -+```text -+Icod.LineEditor.Sed owns Sed-specific script parsing, address and range state, -+pattern and hold spaces, branching, command-cycle behavior, substitutions, -+sandbox policy, and in-place-editing semantics. -+``` -+ -+Add: -+ -+```text -+A general Icod.LineEditor.Shared project is created only if completed Ed and -+Sed implementations demonstrate cohesive editor-family reuse that is neither -+cross-suite Icod.CommandFramework material nor specific to one engine. -+``` -+ -+### Ed milestone -+ -+Replace the stale milestone with: -+ -+```markdown -+### In-solution suite incubation milestone ΓÇö `Icod.LineEditor.Ed` and `Icod.LineEditor.Red` -+ -+- [ ] Create `Icod.LineEditor.Ed.Shared` and its test project. -+- [ ] Retain or rebuild `Icod.LineEditor.Ed` with public -+ `Icod.LineEditor.Ed.Command`. -+- [ ] Retain or complete `Icod.LineEditor.Red` with public -+ `Icod.LineEditor.Red.Command`. -+- [ ] Record GNU ed 1.22.5 as the authoritative baseline. -+- [ ] Put the complete mutable Ed engine and Red restricted-mode policy in -+ `Icod.LineEditor.Ed.Shared`. -+- [ ] Make `red` and `ed --restricted` select the same engine profile. -+- [ ] Consume common regex, record, process, temporary, filesystem, and -+ transactional-replacement contracts from the current Shared incubation -+ project. -+- [ ] Establish textual compatibility fixtures for Ed scripts emitted by GNU -+ Diffutils and `Icod.DiffUtils`. -+- [ ] Preserve lowercase assembly names `ed` and `red`. -+``` -+ -+### Sed milestone -+ -+Replace the stale project names and acknowledge existing progress: -+ -+```markdown -+### In-solution suite incubation milestone ΓÇö `Icod.LineEditor.Sed` -+ -+This milestone preserves completed historical Batch 2 and the already-completed -+project and namespace rename. -+ -+- [ ] Retain `Icod.LineEditor.Sed` with lowercase assembly name `sed` and -+ public `Icod.LineEditor.Sed.Command`. -+- [ ] Rename the stale Sed test project filename and normalize solution display -+ names. -+- [ ] Record GNU sed 4.10 as the authoritative baseline. -+- [ ] Decompose the current monolithic command into internal parser, program, -+ address, execution, record, substitution, process, and file modules. -+- [ ] Extend and consume the Shared GNU regex provider for BRE and ERE rather -+ than translating patterns to .NET Regex. -+- [ ] Consume byte-preserving Shared record and text contracts for LF, NUL, -+ invalid-input, and incomplete-final-record behavior. -+- [ ] Keep Sed-specific pattern space, hold space, address state, branching, -+ command cycle, sandbox policy, and in-place-editing policy in -+ `Icod.LineEditor.Sed`. -+- [ ] Isolate in-place editing now and consume Completion Gate E6 transaction -+ contracts when available. -+- [ ] Do not create `Icod.LineEditor.Shared` merely to wrap existing -+ cross-suite Shared APIs. -+``` -+ -+### Completion Gate G -+ -+Inventory: -+ -+```text -+Icod.LineEditor.Ed.Shared -+``` -+ -+as a definite suite engine. -+ -+Inventory: -+ -+```text -+Icod.LineEditor.Shared -+``` -+ -+only if it was created after the evidence-based sharing audit. -+ -+--- -+ -+## Testing additions required before claiming conformance -+ -+The current Sed test suite is a useful functional baseline, but the revised architecture needs additional categories. -+ -+### Structural characterization -+ -+- every existing test must pass before and after file decomposition; -+- debug output and diagnostic wording should be captured where contractual; -+- option ordering and multiple script-source ordering should be tested; -+- public compatibility overloads should remain functional. -+ -+### Regular expressions -+ -+- GNU BRE grouping, intervals, back-references, alternation extensions, and empty expressions; -+- GNU/POSIX ERE grouping, alternation, intervals, and repetition; -+- leftmost-longest cases that differ from .NET's default behavior; -+- bracket classes and locale providers; -+- invalid-pattern diagnostics with stable source locations; -+- cancellation and resource limits; -+- both address and substitution contexts. -+ -+### Records and encoding -+ -+- LF records; -+- NUL records; -+- CRLF input with CR preserved as data; -+- lone CR; -+- empty records; -+- an unterminated final record; -+- invalid UTF-8 in C and UTF-8 profiles; -+- byte-for-byte unchanged pass-through; -+- huge individual records; -+- pattern space formed from multiple records; -+- hold-space growth; -+- output after `q`, `Q`, `n`, `N`, `D`, and `P`. -+ -+### Script sources -+ -+- multiple `-e` fragments; -+- multiple `-f` files; -+- mixed `-e` and `-f` order; -+- a fragment ending in backslash; -+- comments and labels crossing source boundaries; -+- source-specific diagnostics; -+- no host-line-ending dependence. -+ -+### Sandbox and shell execution -+ -+- compile-time denial; -+- runtime capability denial; -+- `e` commands; -+- substitution `e` flags; -+- file read and write commands; -+- nested or branched paths; -+- child exit status; -+- child stderr; -+- cancellation; -+- Windows and Unix shell invocation profiles. -+ -+### In-place editing -+ -+- exclusive temporary creation; -+- backup suffixes; -+- wildcard backup suffixes; -+- existing backup collision; -+- symlink following and no-follow behavior; -+- mode preservation; -+- timestamps and ownership where supported; -+- write failure; -+- flush failure; -+- backup failure; -+- replacement failure; -+- metadata restoration failure; -+- cancellation; -+- cleanup and rollback; -+- no data loss between original removal and final installation. -+ -+### Ed and Red -+ -+- complete address grammar; -+- current-address transitions; -+- marks; -+- global commands; -+- substitutions; -+- undo; -+- modified-state protection; -+- read/write/file commands; -+- shell filters; -+- signal and cancellation behavior; -+- `red` and `ed --restricted` equivalence; -+- shell denial; -+- parent, absolute, subdirectory, drive-relative, UNC, device, and alternate-stream paths; -+- symlinks, hard links, reparse points, and races; -+- textual Ed scripts emitted by Diffutils. -+ -+--- -+ -+## Final conclusion -+ -+The repository already contains most of the infrastructure that the previous plan proposed placing in `Icod.LineEditor.Shared`. -+ -+Therefore, the proper revised design is: -+ -+```text -+Current Shared incubation project -+ future Icod.CommandFramework candidates -+ Γöé -+ Γö£ΓöÇΓöÇ Icod.LineEditor.Sed -+ Γöé ΓööΓöÇΓöÇ Icod.LineEditor.Sed.Command -+ Γöé -+ ΓööΓöÇΓöÇ Icod.LineEditor.Ed.Shared -+ Γö£ΓöÇΓöÇ Icod.LineEditor.Ed -+ Γöé ΓööΓöÇΓöÇ Icod.LineEditor.Ed.Command -+ ΓööΓöÇΓöÇ Icod.LineEditor.Red -+ ΓööΓöÇΓöÇ Icod.LineEditor.Red.Command -+``` -+ -+The principal decisions are: -+ -+- keep the current Sed project and namespace; -+- decompose Sed before extracting shared editor code; -+- extend the existing Shared regex engine for ERE; -+- migrate Sed away from .NET-regex translation; -+- migrate Sed toward byte-preserving records and exact terminator semantics; -+- preserve the existing Shared process and temporary infrastructure; -+- isolate and later replace Sed's in-place-editing transaction; -+- create `Icod.LineEditor.Ed.Shared` because Ed and Red unquestionably share one engine; -+- implement Red as a restricted profile of that engine; -+- make `Icod.LineEditor.Shared` optional and evidence-based; -+- move true cross-suite abstractions toward `Icod.CommandFramework`, not into another suite wrapper. -+ -+This approach follows the current roadmap's incubation philosophy more closely than the earlier plan. It lets actual consumers determine the final package boundaries and avoids manufacturing a family-level library before the repository has demonstrated what that library would uniquely own. -diff --git a/docs/history/Icod.LineEditor-LE0-Baseline.md b/docs/history/Icod.LineEditor-LE0-Baseline.md -new file mode 100644 -index 0000000000000000000000000000000000000000..35667260c7c85d7356ef5c5b4e88b529ec1a000d ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE0-Baseline.md -@@ -0,0 +1,76 @@ -+# Icod.LineEditor LE0 Baseline -+ -+## Purpose -+ -+This document freezes the repository identity, policy, source shape, and verified CI state immediately before Phase LE1 begins the behavior-preserving decomposition of `Icod.LineEditor.Sed`. -+ -+Phase LE0 does not change command behavior. It normalizes project policy and records the state that later LineEditor phases must preserve or change deliberately. -+ -+## Authoritative upstream baselines -+ -+| Command family | Repository project | Authority | -+|---|---|---| -+| `sed` | `Icod.LineEditor.Sed` | GNU sed 4.10 | -+| `ed`, `red` | `Icod.LineEditor.Ed` and the future `Icod.LineEditor.Ed.Shared` engine | GNU ed 1.22.5 | -+ -+## Public and executable identities -+ -+| Command | Project | Assembly | Root namespace | Public command facade | -+|---|---|---|---|---| -+| `sed` | `Icod.LineEditor.Sed` | `sed` | `Icod.LineEditor.Sed` | `Icod.LineEditor.Sed.Command` | -+| `ed` | `Icod.LineEditor.Ed` | `ed` | `Icod.LineEditor.Ed` | `Icod.LineEditor.Ed.Command` | -+| `red` | `Icod.LineEditor.Red` | `red` | `Icod.LineEditor.Red` | `Icod.LineEditor.Red.Command` | -+ -+The Sed test project is `Icod.LineEditor.Sed.Tests`, stored at `tests/Sed.Tests/Icod.LineEditor.Sed.Tests.csproj`. Ed and Red dedicated test projects are scheduled with their implementation phases. -+ -+The LE0 Red facade preserves the pre-existing seed output and establishes the final public type name only. It is not a GNU Red implementation; Phase LE8 replaces the seed with the `Icod.LineEditor.Ed.Shared` engine under restricted capabilities. -+ -+## Architecture boundary -+ -+- `Icod.LineEditor.Ed.Shared` is the definite owner of the common Ed/Red mutable editor engine. -+- `Icod.LineEditor.Sed` retains Sed-specific parsing, addresses and range state, pattern and hold spaces, branching, command-cycle behavior, substitutions, sandbox policy, and in-place-editing policy. -+- A general `Icod.LineEditor.Shared` is optional. Phase LE9 may create it only after completed Ed and Sed engines demonstrate cohesive family-specific reuse that is neither cross-suite framework material nor specific to one engine. -+ -+## Pre-LE1 source snapshot -+ -+The source snapshot was taken from `main` on August 4, 2026, before LE0 project-file edits. -+ -+| File | Lines | SHA-256 | -+|---|---:|---| -+| `sed/src/Command.cs` | 4,604 | `a9532865c4759c7d8ca9b146b8f2a6a27907512ba6c26ffdb897d3a792b8c40b` | -+| `tests/Sed.Tests/src/SedCommandTests.cs` | 514 | `12fe322ee0dcb55b828f2cf5aeea4ed93644c1a4485fc6e9c9eaa6a75182ba05` | -+ -+The Sed test source declares 30 `[Fact]` cases and one `[Theory]` with three `[InlineData]` rows, for 33 discovered cases under the current xUnit model. -+ -+## Full-solution execution baseline -+ -+The pre-LE0 `main` baseline is GitHub Actions run **build and publish #111**, commit `bb8a087`, triggered August 4, 2026 at 17:42 UTC. The run completed successfully in 3 minutes 25 seconds with all three matrix jobs complete: -+ -+- `windows-latest`; -+- `ubuntu-latest`; -+- `macos-latest`. -+ -+The workflow's successful solution-wide build-and-test result is the behavioral baseline for LE1. The run reported documentation warnings outside the LineEditor scope; those warnings do not change the pass result. -+ -+## Reproduction commands -+ -+Run from the repository root: -+ -+```sh -+dotnet clean Icod.CoreUtils.sln -c Debug -+dotnet restore Icod.CoreUtils.sln -+dotnet build Icod.CoreUtils.sln -c Debug --no-restore -+dotnet test Icod.CoreUtils.sln -c Debug --no-build --logger trx -+``` -+ -+Run the focused Sed baseline with: -+ -+```sh -+dotnet test tests/Sed.Tests/Icod.LineEditor.Sed.Tests.csproj -c Debug --logger trx -+``` -+ -+The required acceptance matrix remains `windows-latest`, `ubuntu-latest`, and `macos-latest`. Local `windows-10` testing is useful additional coverage but does not replace the required CI matrix. -+ -+## LE1 preservation rule -+ -+LE1 may move private implementation types and add characterization coverage, but it must keep the public `Icod.LineEditor.Sed.Command` boundary and existing command behavior stable. Regex, record, encoding, process, sandbox, and transactional semantic changes belong to their later dedicated phases. -diff --git a/docs/history/Icod.LineEditor-LE10-Transactional-Replacement.md b/docs/history/Icod.LineEditor-LE10-Transactional-Replacement.md -new file mode 100644 -index 0000000000000000000000000000000000000000..a836cfcb1671e7e302e448411ce45fd15fe93203 ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE10-Transactional-Replacement.md -@@ -0,0 +1,119 @@ -+# Phase LE10 ΓÇö LineEditor transactional-replacement integration -+ -+Phase LE10 replaces the provisional command-local file-replacement mechanics in -+Sed and Ed with the shared Completion Gate E6 transaction model. The editor -+parsers, mutable-buffer semantics, command diagnostics, backup selection, and -+security profiles remain owned by their existing LineEditor projects. -+ -+## Integration boundary -+ -+```text -+Icod.CoreUtils.Shared.FileSystem.TransactionalReplacement -+ Γåæ -+ Γö£ΓöÇΓöÇ Icod.LineEditor.Ed.Shared.StandardEditorFileAccess -+ ΓööΓöÇΓöÇ Icod.LineEditor.Sed.Command.SystemInPlaceEditor -+``` -+ -+No new LineEditor assembly is introduced. This preserves the Phase LE9 decision -+that neutral filesystem transactions belong in the current Shared incubation -+project while Ed and Sed policy remains in their respective engines. -+ -+## Ed write policy -+ -+`StandardEditorFileAccess` now routes complete-file overwrite and creation -+through `TransactionalFileReplacementTransaction`: -+ -+1. resolve a terminal symbolic link because Ed has no no-follow write profile; -+2. obtain an authoritative E3 observation of the resolved destination; -+3. freeze an E4 no-follow identity precondition, or an absent-destination -+ precondition for a new file; -+4. request best-effort preservation of mode, ownership, and attributes; -+5. write the complete LF-oriented editor buffer into an E6 secure sibling -+ staging file; -+6. require staged-file durability, revalidate identity, publish the replacement, -+ apply metadata, and clean recovery artifacts through the shared transaction. -+ -+Ed append commands remain direct append operations. Append is not whole-file -+replacement and therefore retains its existing write-and-flush policy rather -+than staging a second complete file. Ed command-level force, modified-buffer, -+remembered-filename, and byte-count behavior remains above `IEditorFileAccess`. -+ -+The existing constructor that accepts `SecureTemporaryObjectCreator` and -+`IFileSystemOperations` remains source compatible, but it now composes a -+`SystemTransactionalReplacementFileSystem`; it no longer contains a private -+move/delete replacement algorithm. -+ -+## Sed in-place policy -+ -+`SystemInPlaceEditor` now maps one Sed input file to one E6 recovery unit: -+ -+- the transform callback writes the complete edited result into the transaction's -+ staging stream; -+- a nonempty `-i` backup suffix becomes an explicit retained backup pathname; -+- a pre-existing backup is staged and restored with the destination if a later -+ transaction stage fails; -+- mode, ownership, and attributes are requested as best-effort metadata; -+- cancellation and failure use E6 rollback and deterministic cleanup; -+- the transform's `ExecutionResult` is returned only after the transaction -+ commits successfully. -+ -+Sed retains ownership of GNU backup-suffix expansion, including `*` replacement, -+and of `--follow-symlinks`. When `--follow-symlinks` is selected, Sed resolves -+the final target before constructing E6's mandatory no-follow artifact. Without -+that option, a terminal symbolic link, junction, or other non-ordinary object is -+rejected by the E6 ordinary-file contract. LE10 deliberately supplies no -+nontransactional fallback that would silently weaken rollback or race safety. -+ -+## Transaction and failure semantics -+ -+Both integrations consume the E6 lifecycle rather than reproducing it: -+ -+- exclusive cryptographically named sibling staging files; -+- data-and-metadata flush before namespace publication; -+- stable-identity revalidation immediately before commit; -+- atomic replacement where the provider supports it, with controlled E6 -+ diagnostics for unavailable or fallback atomicity; -+- retained-backup publication from recoverable original content; -+- restoration of both destination and pre-existing backup after a later failure; -+- reverse-order rollback and deterministic cleanup after failure or cancellation. -+ -+Transaction failures are projected through each existing capability as -+`IOException` with the final structured E6 diagnostic as the message and inner -+exception. Cooperative cancellation remains `OperationCanceledException` so the -+command boundary can retain its existing exit-status mapping. -+ -+## Validation matrix -+ -+Dedicated integration tests now cover: -+ -+| Consumer | Coverage | -+|---|---| -+| Ed | staged overwrite lifecycle, creation/byte count, metadata preservation, post-commit rollback, cancellation cleanup, direct append policy, and terminal-symbolic-link target resolution | -+| Sed | staged in-place lifecycle, retained backups, restoration of a pre-existing backup, metadata preservation, cancellation cleanup, default no-follow rejection, and explicit followed-link editing | -+ -+The tests use the system E6 provider for host integration and inject -+`ITransactionalReplacementFailureInjector` at named lifecycle stages for -+deterministic rollback verification. Directory contents are checked after each -+success, failure, and cancellation case to detect leaked staging or recovery -+files. -+ -+## Removed mechanisms -+ -+The following provisional replacement behavior is removed from the editor -+implementations: -+ -+- Ed's private sibling-name loop and direct `File.Move(..., overwrite: true)` -+ publication; -+- Ed's command-local temporary cleanup helper; -+- Sed's private temporary-file creation, backup deletion/move sequence, and -+ best-effort local cleanup path. -+ -+Secure temporary creation remains available only as a compatibility constructor -+input that is immediately composed into the shared system transaction provider. -+ -+## Completion -+ -+Phase LE10 completes the contiguous in-solution LineEditor incubation sequence. -+Completion Gate F1 is the next active roadmap milestone before Batch 46. Final -+repository extraction and package-boundary work remains deferred to Completion -+Gate G. -diff --git a/docs/history/Icod.LineEditor-LE2-Regex-Contract-Audit.md b/docs/history/Icod.LineEditor-LE2-Regex-Contract-Audit.md -new file mode 100644 -index 0000000000000000000000000000000000000000..0cd4031e04640b0926082bbf3853f328b091952d ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE2-Regex-Contract-Audit.md -@@ -0,0 +1,84 @@ -+# Icod.LineEditor Phase LE2 Regex Contract Audit -+ -+## Purpose -+ -+Phase LE2 verifies that the regular-expression foundation completed by Completion Gate R1 is sufficient for the pinned LineEditor consumers before Sed migrates away from its private .NET-regex translation layer. -+ -+Authoritative LineEditor baselines: -+ -+- GNU Sed 4.10; -+- GNU Ed 1.22.5. -+ -+This phase is a contract audit. It does not migrate Sed; that work remains Phase LE3. -+ -+## Result -+ -+No production Shared API extension was required. -+ -+The existing `Icod.CoreUtils.Shared.RegularExpressions` contract already provides the cross-suite mechanics required by Sed and Ed: -+ -+| Contract | LE2 finding | -+|---|---| -+| Syntax | Explicit GNU Basic and Extended profiles exist; Basic remains the source-compatible default. | -+| Parsing | BRE and ERE are parsed directly by the managed parser rather than translated to .NET regular expressions. | -+| Selection | Whole matches use leftmost-longest selection with deterministic GNU/Gnulib capture-register behavior. | -+| Operators | Grouping, alternation, repetition, intervals, bracket expressions, anchors, captures, and GNU extensions are available under the selected profile. | -+| Locale | Classification, comparison, collation, equivalence, and case policy are injected through `IRegularExpressionCharacterClassProvider`; the deterministic C/POSIX provider is suitable for LineEditor conformance tests. | -+| String coordinates | Matches and captures over .NET strings expose UTF-16 indices and lengths. | -+| Byte coordinates | Matches and captures over authoritative input bytes expose exact source-byte offsets, lengths, and slices. | -+| Invalid input | UTF-8 matching has explicit preserve, replace, and throw policies; preserved malformed bytes remain authoritative. | -+| Diagnostics | Compile and match failures use stable structured diagnostics. | -+| Cancellation | Compile, decode, and match operations honor `CancellationToken`. | -+| Resources | Syntax nesting and match-state growth are bounded by explicit options and controlled diagnostics. | -+| Replacement boundary | Shared returns exact matches and captures but does not define a replacement language or output-encoding policy. | -+ -+## Verification added by LE2 -+ -+`LineEditorRegularExpressionContractTests` directly verifies: -+ -+- Basic default compatibility and Extended operator spelling; -+- ERE grouping, alternation, repetition, intervals, brackets, and captures; -+- leftmost-longest selection; -+- line-sensitive anchors; -+- C/POSIX versus Unicode classification; -+- UTF-16 string coordinates; -+- exact UTF-8 source-byte coordinates and capture slices; -+- malformed-byte preservation; -+- invalid byte-boundary diagnostics; -+- deterministic ERE compile diagnostics; -+- match-state resource limits; -+- compile and match cancellation. -+ -+The existing Gate R1 tests remain authoritative for the detailed BRE, GNU Emacs, Gnulib register, bracket, and compatibility profiles. LE2 adds a consumer-oriented acceptance layer rather than duplicating those exhaustive suites. -+ -+## Deliberately excluded from Shared -+ -+The following policies remain in `Icod.LineEditor.Sed`: -+ -+- selecting BRE or ERE from Sed options; -+- remembering and reusing an empty regular expression; -+- distinguishing address and substitution compilation context; -+- POSIX and GNU mode interactions; -+- repeated-match and zero-length-match progression; -+- substitution occurrence selection; -+- replacement-template parsing; -+- replacement-output encoding; -+- Sed-specific diagnostics. -+ -+Ed likewise owns editor command context, remembered expressions, substitutions, and mutable-buffer effects. These policies are not general regex-engine mechanics. -+ -+## LE3 handoff -+ -+Phase LE3 may now introduce a Sed-specific adapter over the Shared provider. The migration should compare the current private translator and Shared engine with GNU Sed differential fixtures before deleting the old path. Any discrepancy found during that migration should first be classified as either: -+ -+1. a genuine cross-suite regex defect, which belongs in Shared; or -+2. Sed-specific orchestration or replacement policy, which remains in `Icod.LineEditor.Sed`. -+ -+## LE4 consumer-evidence follow-up -+ -+The LE2 conclusion was correct for the LF-oriented consumers and fixtures available at that phase. Phase LE4 supplied the first concrete GNU Sed `--null-data` multiline consumer and exposed one narrow cross-suite gap: line-sensitive matching had hard-coded LF, while GNU Sed `-z` uses NUL as the pattern-space line separator. LE4 therefore adds two command-neutral options to the existing Shared contract: -+ -+- `RegularExpressionOptions.LineSeparator`, defaulting to LF; -+- `RegularExpressionOptions.DotMatchesNull`, defaulting to `false`. -+ -+The defaults preserve every pre-LE4 consumer. Sed selects NUL and enables NUL dot matching only for `-z`; when the `M` modifier enables line sensitivity, the configured NUL separator is again excluded by dot and negated bracket expressions and is used by `^` and `$`. This extension is general regex matching policy backed by real consumer evidence. Sed still owns `-z`, pattern-space construction, modifier syntax, and empty-expression state. -diff --git a/docs/history/Icod.LineEditor-LE3-Regex-Migration.md b/docs/history/Icod.LineEditor-LE3-Regex-Migration.md -new file mode 100644 -index 0000000000000000000000000000000000000000..3f08ccd804b85923b2cc2a2eb2c43fe7e3683753 ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE3-Regex-Migration.md -@@ -0,0 +1,108 @@ -+# Icod.LineEditor Phase LE3 ΓÇö Sed regular-expression migration -+ -+## Status -+ -+Phase LE3 is complete. `Icod.LineEditor.Sed` no longer translates BRE text or POSIX classes into `System.Text.RegularExpressions`. Address and substitution expressions now compile through `Icod.CoreUtils.Shared.RegularExpressions`. -+ -+The authoritative behavior baseline is GNU sed 4.10, with the Shared provider contract verified during Phase LE2. -+ -+## Ownership boundary -+ -+The migration deliberately separates reusable regex mechanics from Sed policy. -+ -+### Shared provider owns -+ -+- direct GNU Basic and Extended syntax parsing; -+- leftmost-longest match selection; -+- numbered captures and back-references; -+- locale-aware character classes and collation through an injected provider; -+- line-sensitive anchors and dot/bracket policy over a caller-selected logical separator; -+- controlled compile and match diagnostics; -+- cancellation and resource limits. -+ -+### `Icod.LineEditor.Sed` owns -+ -+- BRE versus ERE selection from command options; -+- the last-compiled-expression state shared by addresses and substitutions; -+- exact empty-expression reuse, including prior `I` and `M` policy; -+- address `I`/`M` and substitution `i`/`I`/`m`/`M` syntax; -+- GNU versus POSIX interpretation of command-local extensions; -+- GNU Sed escape preprocessing before BRE/ERE parsing, including strict-POSIX bracket behavior; -+- global and numbered substitution occurrence selection; -+- GNU empty-match iteration; -+- replacement expansion and Sed diagnostic presentation. -+ -+No general `Icod.LineEditor.Shared` project was introduced. -+ -+## Implementation -+ -+`SedRegularExpressionCompiler` is a private nested implementation type behind the established public `Icod.LineEditor.Sed.Command` facade. One compiler is created for each parsed Sed program. It selects either `GnuBasicRegularExpressionProvider` or `GnuExtendedRegularExpressionProvider`, injects the applicable character-class provider, and retains the last successful compiled expression. -+ -+A nonempty address or substitution expression replaces that retained object. An empty expression returns the exact object rather than recompiling the prior pattern. This preserves the modifier policy under which the expression was originally compiled. GNU Sed rejects new modifiers on an empty expression, and the adapter does the same. -+ -+Before compilation, the adapter performs GNU Sed escape preprocessing for control, decimal, octal, and hexadecimal escapes. The preprocessing occurs before BRE/ERE parsing, so numeric escapes may generate regular-expression metacharacters. Under `--posix`, GNU escape processing remains active outside raw bracket expressions but is disabled inside them. This policy remains command-local because it is Sed source-language behavior rather than a property of the Shared regex grammar. -+ -+Invariant .NET culture selects `PosixCLocaleRegularExpressionCharacterClassProvider`; other cultures use `UnicodeRegularExpressionCharacterClassProvider` for the process culture. Phase LE4 will replace the current decoded-string record path with explicit byte/text and encoding policy. -+ -+## GNU empty-match progression -+ -+Shared returns one leftmost-longest match from a requested start index. Sed layers global iteration above that primitive. -+ -+The iterator: -+ -+1. accepts a zero-length match when it is not immediately adjacent to a preceding accepted nonempty match; -+2. advances one input character after an accepted zero-length match; -+3. suppresses an empty match immediately following an accepted nonempty match; -+4. continues after the suppressed position when input remains; -+5. retains exact capture data from Shared for replacement expansion. -+ -+This reproduces GNU Sed cases such as: -+ -+| Program | Input | Output | -+|---|---|---| -+| `s/x*/X/g` | `abc` | `XaXbXcX` | -+| `s/a*/X/g` | `ab` | `XbX` | -+| `s/b*/X/g` | `ab` | `XaX` | -+| `s/[a-z]*/X/g` | `abc` | `X` | -+ -+## Differential and acceptance coverage -+ -+`SedRegularExpressionMigrationTests` includes GNU sed 4.10 expected results for: -+ -+- BRE grouping, captures, and replacement back-references; -+- ERE syntax; -+- leftmost-longest alternation where .NET's default leftmost-first engine chooses a shorter branch; -+- exact empty-expression reuse across address and substitution contexts; -+- rejection of modifiers on an empty expression; -+- multiline anchors; -+- C-locale POSIX character classes; -+- POSIX-mode handling of GNU-only BRE operators and GNU escapes inside raw bracket expressions; -+- GNU control, decimal, octal, hexadecimal, tab, and newline escape preprocessing; -+- repeated zero-length global substitutions; -+- translation of Shared diagnostics into Sed usage diagnostics. -+ -+The pre-existing command and LE1 characterization suites remain in place. -+ -+## Removed implementation -+ -+The following command-local compatibility layer is removed: -+ -+- `CreateRegex`; -+- `TranslateBasicRegularExpression`; -+- `TranslatePosixClasses`; -+- all production references to `System.Text.RegularExpressions`. -+ -+## Deferred to LE4 and later -+ -+LE3 intentionally does not claim byte-perfect Sed input semantics. The following remain scheduled: -+ -+- byte-preserving input records and exact final-record termination; -+- explicit LF/NUL output serialization; -+- invalid UTF-8 and C-locale byte behavior; -+- CR preservation; -+- byte-to-text and replacement-encoding policy; -+- hardened script-source, sandbox, process, and in-place replacement phases. -+ -+## LE4 completion handoff -+ -+Phase LE4 has now resolved the byte-record items deferred by this migration. Sed regex matching receives text from the explicit C/POSIX byte or UTF-8 profile, malformed bytes survive through deterministic placeholders, LF/NUL framing and final termination are authoritative record metadata, and replacement output is encoded through the same selected profile. Concrete `-z` multiline use also justified a narrow Shared extension: Sed selects NUL through `RegularExpressionOptions.LineSeparator` and explicitly allows dot to consume NUL outside multiline mode. The detailed contract is recorded in `Icod.LineEditor-LE4-Record-and-Text-Semantics.md`. -diff --git a/docs/history/Icod.LineEditor-LE4-Record-and-Text-Semantics.md b/docs/history/Icod.LineEditor-LE4-Record-and-Text-Semantics.md -new file mode 100644 -index 0000000000000000000000000000000000000000..e597581218569dbe0ee04a721750a131e9d05e4f ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE4-Record-and-Text-Semantics.md -@@ -0,0 +1,108 @@ -+# Icod.LineEditor LE4 Record and Text Semantics -+ -+## Status -+ -+Phase LE4 is complete. This phase changes Sed data semantics without changing the public `Icod.LineEditor.Sed.Command.Run` and `RunAsync` text-stream compatibility signatures, the LE3 regular-expression ownership boundary, or the command-local in-place replacement mechanism scheduled for later hardening. -+ -+## Record model -+ -+`SedInputRecord` is a private command implementation type with the following authoritative state: -+ -+- independently owned source bytes excluding the separator; -+- decoded working text; -+- source identity and source index; -+- aggregate record number; -+- per-source record number; -+- LF or NUL separator kind; -+- explicit final-record termination; -+- text-boundary-to-source-byte coordinates where a boundary is representable. -+ -+Input is framed by Shared `ByteRecordReader`. LF mode removes only byte `0x0A`; a preceding carriage return remains part of the record. NUL mode removes only byte `0x00`. `InputSequence` retains the current record and one lookahead record so `$` can be evaluated without materializing the remainder of the input. -+ -+## Text profiles -+ -+Sed resolves the process text profile through `TextLocaleEnvironment`. -+ -+### C and POSIX -+ -+`LC_ALL=C` or `LC_ALL=POSIX` selects the byte profile. Each source byte maps to one working character with the same numeric value. POSIX character classes therefore classify ASCII/C-locale bytes rather than decoded Unicode scalars. Existing bytes from `0x00` through `0xFF` encode back to the same byte. Newly inserted characters outside that range use UTF-8 as the deterministic replacement encoding. -+ -+### UTF-8 -+ -+Other locale names select the UTF-8 profile. Well-formed UTF-8 maps to Unicode scalars. Each malformed source byte maps to a reserved unpaired UTF-16 low-surrogate code unit that cannot arise from well-formed UTF-8. Shared string matching evaluates that opaque unit as U+FFFD while retaining the original source code unit and indices; the Sed adapter therefore preserves it through pattern/hold-space operations and maps it back to the original byte on output. Ordinary inserted text is UTF-8 encoded. -+ -+The record retains byte offsets for valid working-text boundaries. Boundaries inside a surrogate pair are deliberately non-authoritative. -+ -+## Output and termination -+ -+All Sed data output goes through Shared `DelimitedByteRecordWriter`. -+ -+- normal mode emits byte `0x0A` only when the record's termination policy requires it; -+- `-z` emits byte `0x00` only when termination is required; -+- `TextWriter.NewLine`, `Environment.NewLine`, and the host platform do not select data separators; -+- generated records such as `=`, `l`, inserted text, changed text, and appended text are explicitly terminated; -+- pattern-space printing preserves the active termination state; when another output operation follows an unterminated record, Sed inserts exactly one configured separator before that later output, matching GNU Sed output-stream state; -+- `P` and `W` terminate when they emit a complete internal line, otherwise they preserve the active state; -+- `N`, `h`, `H`, `g`, `G`, and `x` propagate the termination state associated with the resulting pattern or hold space; -+- internal multiline operations use LF in ordinary mode and NUL under `-z`; this includes `N`, `D`, `P`, `H`, `G`, and `W`; -+- `l` renders an internal NUL separator as `\000`, matching GNU Sed list output. -+ -+The executable entry point uses `Console.OpenStandardInput` and `Console.OpenStandardOutput`, so byte data is not decoded by `Console.In` before Sed receives it. The established public text-stream facade remains available through streaming compatibility adapters for tests and callers that intentionally operate on .NET text streams. -+ -+## NUL-aware regular-expression contract -+ -+LE4 provided the first concrete consumer evidence that Shared line-sensitive matching could not remain hard-coded to LF. `RegularExpressionOptions.LineSeparator` now defaults to LF but may be set to NUL, and `DotMatchesNull` explicitly controls the Basic/Extended default NUL exclusion. Sed configures both from `-z`: -+ -+- without `M`, dot may consume an internal NUL in NUL-data pattern space; -+- with `M`, NUL is the logical line boundary, so `^` and `$` recognize positions around NUL and dot or a negated bracket expression does not consume it; -+- an ordinary LF remains data in `-z` pattern space and is not treated as a multiline boundary. -+ -+The defaults retain the pre-LE4 Shared behavior. Sed continues to own `-z` syntax, pattern-space assembly, and modifier policy. -+ -+## Memory invariant -+ -+Sed does not retain unrelated completed input records. Shared framing materializes one logical record at a time, and `InputSequence` retains one additional lookahead record solely for last-record addressing. -+ -+This is not a fixed-memory guarantee for the current command state. GNU Sed semantics permit: -+ -+- one input record to be arbitrarily large; -+- `N` to grow pattern space; -+- `H`, `G`, `h`, `g`, and `x` to grow or exchange pattern and hold spaces; -+- substitutions, transliteration, shell output, and inserted data to expand the active text. -+ -+The bounded invariant applies to unrelated stream history, not to the record, pattern space, or hold space that Sed is required to retain. -+ -+## Acceptance coverage -+ -+`SedRecordAndTextSemanticsTests` covers: -+ -+- CRLF without CR normalization; -+- lone CR data; -+- empty LF records; -+- host-newline independence; -+- NUL framing and an unterminated final NUL record; -+- NUL-backed `N`, `P`, `H`, `G`, `D`, `W`, and `l` pattern-space behavior; -+- NUL-aware dot and multiline-anchor behavior, including preservation of embedded LF as ordinary data; -+- multiline pattern-space termination and `P` first-line termination; -+- separation between consecutive LF and NUL output operations after an unterminated record; -+- preservation of that output-stream state across `-s` input-file boundaries; -+- hold-space growth and termination; -+- a one-megabyte logical record followed by another record; -+- malformed UTF-8 round-trip through in-place editing; -+- C-byte versus UTF-8 character-class behavior; -+- the required private record metadata surface. -+ -+The LE1 characterization for unterminated output is intentionally updated: LE4 now preserves the missing final separator rather than synthesizing one. -+ -+## Deferred work -+ -+LE4 does not: -+ -+- replace script-fragment joining with source objects; -+- add the final `CommandContext` core overload; -+- harden shell and auxiliary-file capabilities; -+- complete sandbox runtime denial; -+- replace command-local in-place editing with E6; -+- create a general `Icod.LineEditor.Shared` project. -+ -+Those responsibilities remain assigned to LE5 and the later LineEditor phases. -diff --git a/docs/history/Icod.LineEditor-LE5-Orchestration-and-Capabilities.md b/docs/history/Icod.LineEditor-LE5-Orchestration-and-Capabilities.md -new file mode 100644 -index 0000000000000000000000000000000000000000..a979a551385699d1c785d9bd5d646814f30e1b71 ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE5-Orchestration-and-Capabilities.md -@@ -0,0 +1,71 @@ -+# Icod.LineEditor Phase LE5 ΓÇö Sed orchestration and capability boundary -+ -+## Status -+ -+Phase LE5 is complete. The public `Icod.LineEditor.Sed.Command` identity and established text-stream compatibility overloads remain available, while the repository-standard `RunAsync(string[] args, CommandContext context)` path is now the primary command entry point. -+ -+LE5 is an orchestration and side-effect-boundary phase. It does not redesign Sed's regex, record, pattern-space, or hold-space semantics established by LE3 and LE4, and it does not perform the final E6 transactional-replacement migration scheduled for LE10. -+ -+## CommandContext core -+ -+`Command.RunAsync(string[] args, CommandContext context)` now carries the command's standard streams and cancellation token. When `CommandContext` supplies a binary standard-input or standard-output stream, Sed uses that stream independently as the authoritative side of the data path so the LE4 byte-preserving contract is retained. A missing binary side uses the established text compatibility adapter. The text streams remain available for diagnostics and, when no binary output stream is present, presentation output. -+ -+The executable entry point now constructs `CommandContext` and invokes this overload directly. The synchronous `Run`, text-stream `RunAsync`, and internal byte-stream entry points remain compatibility facades. -+ -+## Script-source model -+ -+Command-line `-e` expressions, `-f` script files, and the implicit first script operand are represented by separate `SedScriptSource` objects. Each source retains: -+ -+- its source kind; -+- a stable diagnostic name; -+- its original text; -+- its invocation order. -+ -+`SedScriptDocument` provides one parser view while preserving source spans. Adjacent sources are separated with a literal LF only when the preceding source does not already end in LF. `Environment.NewLine` is not used for script composition. Parser diagnostics map aggregate positions back to a source name and one-based line and column. -+ -+## Runtime capabilities -+ -+`SedRuntimeCapabilities` groups three private command capabilities: -+ -+- `ISedShellCapability` for `e` and `s///e`; -+- `ISedAuxiliaryFileCapability` for `r`, `R`, `w`, `W`, and substitution `w`; -+- `IInPlaceEditor` for `-i` publication. -+ -+The system shell implementation retains Shared `ProcessRunner`; LE5 does not introduce direct process spawning. The system auxiliary-file capability retains asynchronous `FileStream` operations behind the boundary. Tests can inject deterministic in-memory or failure-producing capabilities without touching the host shell or filesystem. -+ -+## Sandbox enforcement -+ -+Sandbox restrictions remain enforced by the script compiler: shell-bearing and auxiliary-file commands are rejected before execution. LE5 also supplies a denied runtime capability profile. Consequently, a command that reaches either capability through a future parser or dispatcher regression still receives a controlled denial instead of host access. -+ -+The in-place editor remains available in sandbox mode because GNU Sed sandbox restrictions apply to `e`, `r`, and `w` command families rather than to the command-line `-i` mode itself. -+ -+## In-place editing boundary -+ -+The existing command-local replacement mechanics now live entirely inside `SystemInPlaceEditor`. It: -+ -+- resolves `--follow-symlinks` according to the existing policy; -+- creates a private sibling temporary file through Shared `SecureTemporaryObjectCreator`; -+- invokes the Sed transformation against that file; -+- preserves the existing backup, replacement, attribute, and Unix-mode behavior; -+- removes the temporary file after a failed or canceled transformation. -+ -+This is intentionally a boundary and characterization step. LE10 remains responsible for replacing these provisional publication internals with the shared E6 transaction model and its complete rollback, metadata, durability, and indirection policy. -+ -+## Acceptance coverage -+ -+`SedOrchestrationAndCapabilityTests` verifies: -+ -+- independent binary-input and binary-output selection by `CommandContext`; -+- LF-only script composition and source-location mapping; -+- stable diagnostics for later `-e` sources; -+- injected shell execution; -+- injected auxiliary reads and writes; -+- compile-time sandbox rejection and runtime denied-capability backstops; -+- delegation of `-i` to `IInPlaceEditor`; -+- source preservation and temporary-file cleanup after an injected in-place transformation failure. -+ -+The established command, LE3 regex, and LE4 record/text suites remain in place. -+ -+## Handoff to LE6 -+ -+LE6 may now design `Icod.LineEditor.Ed.Shared` against the proven Shared regex, record, process, temporary-object, filesystem, and capability patterns without coupling Ed/Red to Sed's streaming engine. Sed's final E6 replacement migration remains deferred to LE10 as planned. -diff --git a/docs/history/Icod.LineEditor-LE6-Ed-Shared-Engine.md b/docs/history/Icod.LineEditor-LE6-Ed-Shared-Engine.md -new file mode 100644 -index 0000000000000000000000000000000000000000..f7fba81fe0727b74e6fe985d2d088ed0298292b3 ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE6-Ed-Shared-Engine.md -@@ -0,0 +1,77 @@ -+# Icod.LineEditor Phase LE6 ΓÇö Ed/Red Shared Engine -+ -+## Status -+ -+Phase LE6 creates `Icod.LineEditor.Ed.Shared` and its dedicated `Icod.LineEditor.Ed.Shared.Tests` project in the current solution. The phase establishes the reusable mutable editor engine required by both standard Ed and restricted Red without migrating either executable prematurely. -+ -+## Project boundary -+ -+The public engine namespace is `Icod.LineEditor.Ed`; the assembly is `Icod.LineEditor.Ed.Shared`. -+ -+The shared engine owns: -+ -+- mutable line-buffer mechanics; -+- Ed addresses and ranges; -+- marks and cut-buffer state; -+- substitutions and global commands; -+- undo and remembered session state; -+- file and shell capability orchestration; -+- diagnostics, cooperative signals, cancellation, and controlled exit statuses; -+- immutable standard and restricted security profiles. -+ -+The engine does not own: -+ -+- the `ed` or `red` process-level command line; -+- Sed's pattern-space, hold-space, range-state, or streaming-cycle semantics; -+- cross-suite regular-expression, record, process, temporary, or filesystem foundations; -+- Diffutils runtime implementation types. -+ -+## Buffer and state model -+ -+`EditorBuffer` stores lines in bounded segments rather than one monolithic `List`. Each inserted line receives a stable nonzero identity. Moves preserve identity; copies allocate new identities; substitutions and joins retain the identity of the surviving line. Marks and global-command selections therefore survive address movement and can detect deleted lines deterministically. -+ -+The engine records: -+ -+- current and last addresses; -+- marks `a` through `z`; -+- the cut buffer; -+- the most recent regular expression, replacement, and shell command; -+- the remembered filename where permitted; -+- final-record termination; -+- modified state; -+- one reversible undo snapshot. -+ -+## Shared contract consumption -+ -+The implementation directly consumes the current Shared incubation APIs: -+ -+- `GnuBasicRegularExpressionProvider` and `ICompiledRegularExpression` for Ed searches and substitutions; -+- `ByteRecordReader` for LF-delimited script and file records; -+- `ProcessRunner` for host shell execution; -+- `SecureTemporaryObjectCreator` and `TemporaryNameTemplate` for sibling staging files; -+- `IFileSystemOperations.FlushFileAsync` for durability requests. -+ -+No parallel regular-expression, process-runner, temporary-name, or filesystem-durability abstraction is introduced. -+ -+## Capability and security model -+ -+`IEditorFileAccess` and `IEditorProcessAccess` contain all external effects. Standard implementations use Shared infrastructure. Denied implementations fail without touching the host. `RestrictedEditorFileAccess` resolves validated simple logical filenames beneath one captured working directory and rejects rooted, directory-bearing, alternate-stream, symbolic-link, and reparse-point leaves before delegation. -+ -+`EditorSecurityPolicy` is immutable. The restricted profile rejects shell commands in the dispatcher and uses a denied process capability, providing defense in depth. The engine preserves logical simple filenames rather than converting them to absolute paths before capability validation. -+ -+LE8 remains responsible for complete GNU Red pathname and race conformance, including hard-link and validation/open race analysis. Phase LE6 supplies the mandatory injection and policy boundaries so that work does not require another engine. -+ -+## Compatibility fixtures -+ -+The dedicated test project contains textual fixtures for: -+ -+- a GNU Diffutils-style ed script using change and append commands; -+- an `Icod.DiffUtils`-style ed script using append, delete, and substitution commands. -+ -+The tests load the original text, execute the script through `EditorEngine`, and compare the resulting buffer to the expected text. The test project has no runtime dependency on `Icod.DiffUtils.Shared`. -+ -+## Phase boundary -+ -+Phase LE7 replaces the current `Icod.LineEditor.Ed.Command` seed internals with this engine under the standard profile and adds GNU ed 1.22.5 command-line and conformance coverage. -+ -+Phase LE8 makes `Icod.LineEditor.Red.Command` and `ed --restricted` select the same restricted engine profile and completes adversarial platform-path and confinement testing. -diff --git a/docs/history/Icod.LineEditor-LE7-Ed-Command.md b/docs/history/Icod.LineEditor-LE7-Ed-Command.md -new file mode 100644 -index 0000000000000000000000000000000000000000..c9da50c1a78e6d1a48b7ccfe61b36086125912ce ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE7-Ed-Command.md -@@ -0,0 +1,148 @@ -+# LineEditor Phase LE7 ΓÇö `ed` command migration -+ -+## Scope -+ -+Phase LE7 replaces the historical `ed` seed with a command/session host over -+`Icod.LineEditor.Ed.Shared`. The reusable engine remains authoritative for Ed -+addresses, mutable buffer state, stable line identity, marks, cut buffers, -+substitution, global execution, undo, file commands, shell filters, and -+controlled engine diagnostics. The executable owns GNU ed 1.22.5 invocation -+and process-boundary policy. -+ -+The public and packaging identities remain: -+ -+```text -+Icod.LineEditor.Ed.Command -+assembly: ed -+framework: net10.0 -+language: C# 13 -+``` -+ -+## Command boundary -+ -+`Command` exposes the repository's established forms: -+ -+- synchronous `Run` over optional text streams; -+- cancellation-aware `RunAsync` over text streams; -+- byte-preserving `RunAsync` over `Stream` instances; -+- the primary `RunAsync(string[] args, CommandContext context)` path; -+- a dedicated usage writer. -+ -+When `CommandContext` supplies binary standard streams, those streams are -+used directly. The text-only compatibility path bridges through UTF-8 without -+taking ownership of caller-provided readers or writers. -+ -+`Program.Main` is asynchronous, creates the console `CommandContext`, and -+maps Ctrl+C to cooperative cancellation. -+ -+## GNU invocation policy -+ -+The command parser accepts the GNU ed 1.22.5 options implemented by the -+pinned profile: -+ -+```text -+-E, --extended-regexp -+-G, --traditional -+-l, --loose-exit-status -+-p, --prompt=STRING -+-q, --quiet, --silent -+-r, --restricted -+-s, --script -+-v, --verbose -+--strip-trailing-cr -+--unsafe-names -+-h, --help -+-V, --version -+``` -+ -+It also accepts the GNU operand shape: -+ -+```text -+ed [OPTION]... [[+LINE] FILE] -+``` -+ -+Initial address selection supports `+`, numeric addresses, forward regular -+expression searches, and reverse regular expression searches. A numeric -+address beyond the loaded buffer selects the last line, matching the pinned -+GNU behavior. -+ -+Option parsing uses the shared `OptionParser` and conventional option -+formatting. Long-option abbreviation and option permutation remain aligned -+with the repository's GNU command-line policy. -+ -+## Session orchestration -+ -+The executable reads command records through `ByteRecordReader` and supplies -+one complete command unit to the shared engine. Input blocks for `a`, `i`, and -+`c` remain attached to the command and require the single-period terminator. -+LF is the editor's command and data separator; a CR immediately preceding a -+terminated command-record LF is accepted as CRLF command input. -+ -+The host owns session-only behavior that is not mutable-engine state: -+ -+- prompting and `P` toggling; -+- verbose-help mode and `H` toggling; -+- the second `q` or `e` modified-buffer override; -+- `-s` suppression of byte-count presentation; -+- `-q` suppression of diagnostics without suppressing child-process stderr; -+- routing the `h` help message to standard output; -+- noninteractive versus interactive error continuation; -+- cancellation, broken-stream, and final exit-status mapping. -+ -+## Capability composition -+ -+Normal `ed` composes: -+ -+```text -+EditorSecurityPolicy.Standard -+StandardEditorFileAccess -+StandardEditorProcessAccess -+Shared GNU BRE provider (or ERE under -E) -+``` -+ -+`ed --restricted` composes the same immutable restricted engine profile that -+Phase LE8 will use for `red`. File operations pass through the command's GNU -+filename-control policy before reaching the engine capability. Newline and -+NUL are always rejected in filenames; `--unsafe-names` permits the remaining -+GNU-listed control characters. Shell-bearing initial operands and commands -+are denied by the restricted process profile. -+ -+`--strip-trailing-cr` removes CR only when it is the CR member of a terminated -+CRLF record. A CR ending an unterminated final record remains data. -+ -+## Exit-status policy -+ -+The executable maps the pinned GNU categories as follows: -+ -+```text -+0 normal completion, including loose-exit-status completion -+1 command-line, command, environment, or output failure -+2 interrupted execution, modified-buffer refusal, or initial-file problem -+``` -+ -+The shared engine continues to return structured diagnostics and signal state; -+the executable determines whether and where those diagnostics are presented. -+ -+## Command-level validation -+ -+The dedicated `tests/Ed.Tests` project exercises the public command API and -+covers: -+ -+- help, version, option, prompt, quiet, script, verbose, and loose modes; -+- standard and restricted capability composition; -+- BRE and ERE command execution; -+- initial line selection and oversized-address clamping; -+- file loading, byte-count suppression, writing, and CR policy; -+- modified state and controlled diagnostics; -+- cancellation and broken output; -+- long lines and large line counts; -+- text-only `CommandContext` compatibility; -+- GNU Diffutils-style and Icod Diffutils-style ed-script fixtures without a -+ runtime Diffutils dependency. -+ -+## Phase boundary -+ -+Phase LE7 does not create a second mutable editor implementation and does not -+change `red`. Phase LE8 retains `Icod.LineEditor.Red.Command`, hosts this same -+engine, and makes `red` and `ed --restricted` select the same restricted -+profile with the required adversarial path and shell tests. -diff --git a/docs/history/Icod.LineEditor-LE8-Red-Restricted-Profile.md b/docs/history/Icod.LineEditor-LE8-Red-Restricted-Profile.md -new file mode 100644 -index 0000000000000000000000000000000000000000..4fbb28671be988c3c37c79b7ab9c141ab1c8fa9a ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE8-Red-Restricted-Profile.md -@@ -0,0 +1,58 @@ -+# Phase LE8 ΓÇö Red restricted profile -+ -+Phase LE8 migrates the lowercase `red` executable onto the mutable engine created in LE6 while retaining the public `Icod.LineEditor.Red.Command` identity. -+ -+## Shared profile -+ -+`red` and `ed --restricted` now construct the same immutable `EditorCapabilityProfile`. The profile binds together: -+ -+- `EditorSecurityPolicy.Restricted`, with one working directory captured at construction; -+- `RestrictedEditorFileAccess`, which is the only file capability exposed to the engine; and -+- `DeniedEditorProcessAccess`, which refuses every child-process request. -+ -+The executable has no unrestricted composition branch. The `-r`/`--restricted` option is accepted only for GNU-compatible invocation syntax. -+ -+## Shell denial -+ -+Restricted shell denial is enforced at two layers: -+ -+1. The engine preflights command text before address parsing, global selection, undo capture, current-address movement, remembered-command lookup, or any other mutable transition. Direct `!`, remembered `!!`, addressed filters, and shell commands nested inside `g` or `v` are rejected there. -+2. The immutable profile exposes `DeniedEditorProcessAccess`, so a future parser or dispatcher defect still cannot start a child process through the engine capability. -+ -+The early check is recursive for global-command bodies and is intentionally performed before resolving marks or search addresses. Denied commands therefore preserve buffer identities and content, current address, marks, modified state, remembered filename, and the prior undo unit. -+ -+## Filename policy -+ -+`EditorRestrictedPath.IsSimpleFileName` applies the same classification on every host. It rejects: -+ -+- Unix rooted and slash-bearing paths; -+- Windows drive-relative and rooted paths; -+- backslash-bearing, UNC, and device paths; -+- alternate data streams and other colon-bearing names; -+- `.` and `..`; -+- shell-bearing names; -+- Windows reserved device stems; and -+- trailing-dot and trailing-space aliases. -+ -+Permitted names are resolved beneath the directory captured when the profile is constructed. The logical remembered filename remains the simple name rather than the resolved absolute path. -+ -+## Confinement contract -+ -+The GNU-compatible profile provides pathname restriction. It does **not** claim physical filesystem confinement. A permitted leaf can refer to a symbolic link, hard link, mount point, or reparse point, and the operating system resolves it according to normal filesystem rules. The capability deliberately avoids a separate link/reparse precheck because a check followed by open would add a validation/open race without establishing a reliable sandbox. -+ -+Callers requiring physical confinement must provide a stronger filesystem capability with handle-relative, no-follow, or equivalent platform-specific guarantees. That stronger boundary is outside GNU `red` compatibility and is not claimed by LE8. -+ -+## Validation -+ -+The LE8 tests cover: -+ -+- `red` identity and ordinary editing; -+- equivalence with `ed --restricted` for successful and failing scripts; -+- direct, remembered, addressed-filter, initial-operand, and global-nested shell denial; -+- Unix, Windows drive, UNC, device, alternate-stream, reserved-device, and trailing-alias paths; -+- permitted simple-name reads and writes; -+- preservation of mutable editor state and the prior undo unit after denial; -+- captured-directory behavior and host-independent classification; and -+- characterization of symbolic-link, hard-link/reparse, and validation/open-race behavior under the documented pathname-only contract. -+ -+Command-host sharing between `ed` and `red` remains intentionally unrefactored until the evidence-based LE9 sharing audit. -diff --git a/docs/history/Icod.LineEditor-LE9-Sharing-Audit.md b/docs/history/Icod.LineEditor-LE9-Sharing-Audit.md -new file mode 100644 -index 0000000000000000000000000000000000000000..a8252922acc0d7f254d06157d90fa947c87b635c ---- /dev/null -+++ b/docs/history/Icod.LineEditor-LE9-Sharing-Audit.md -@@ -0,0 +1,196 @@ -+# LineEditor Phase LE9 ΓÇö Evidence-Based Sharing Audit -+ -+## Status -+ -+Phase LE9 is complete. -+ -+The audit found no cohesive residual library that justifies creating -+`Icod.LineEditor.Shared`. Cross-suite facilities already belong to the current -+`Icod.CoreUtils.Shared` incubation project, the mutable Ed/Red engine remains -+in `Icod.LineEditor.Ed.Shared`, and Sed's streaming program model remains in -+`Icod.LineEditor.Sed`. -+ -+No production API was moved during LE9. That is the intended result of the -+evidence-based audit: every plausible cross-suite API had already been moved -+or consumed during earlier gates and LineEditor phases, while the remaining -+similar-looking code has different grammar, state, diagnostic, and security -+semantics. -+ -+## Audit method -+ -+The audit compared the completed LE5 Sed boundary with the completed LE6ΓÇôLE8 -+Ed/Red implementation. A candidate was considered shareable only when all of -+the following were true: -+ -+1. at least two completed consumers need the same semantic contract; -+2. the consumers agree on parsing, state, diagnostics, cancellation, and -+ resource ownership; -+3. the candidate is not already a cross-suite Shared responsibility; -+4. extracting it reduces duplication without introducing an editor-family -+ dependency into a broader suite; -+5. the resulting API is cohesive enough to justify an assembly and eventual -+ package boundary. -+ -+Similarity of command letters, delimiter characters, or helper method names -+was not treated as consumer evidence. -+ -+## Classification result -+ -+| Candidate | Classification | Current owner | Consumer evidence and decision | -+|---|---|---|---| -+| command context and standard-stream ownership | cross-suite framework candidate | `Icod.CoreUtils.Shared.Diagnostics` | Sed, Ed, Red, and non-editor commands consume the same command boundary. Keep in Shared incubation. | -+| option parsing and common command diagnostics | cross-suite framework candidate | current Shared command-line and diagnostics areas | Used across suites. Editor-specific wording and source locations remain command-owned. | -+| LF/NUL record framing and final-record termination | cross-suite framework candidate | `Icod.CoreUtils.Shared.Records` | Sed consumes byte-preserving stream records; Ed consumes LF-framed script and file records. The framing contract is shared, not editor-specific. | -+| managed GNU BRE/ERE matching | cross-suite framework candidate | `Icod.CoreUtils.Shared.RegularExpressions` | Sed and Ed both consume the Shared provider, as do regex-oriented commands outside LineEditor. Keep matching semantics neutral; keep command-specific pattern reuse and replacement policy local. | -+| process execution | cross-suite framework candidate | `Icod.CoreUtils.Shared.Processes` | Sed shell commands and Ed standard process capability use Shared `ProcessRunner`; Red wraps that boundary with denial. No LineEditor wrapper is justified. | -+| secure temporary objects and filesystem durability | cross-suite framework candidate | current Shared temporary and filesystem areas | Ed file access already consumes these contracts. Sed's provisional in-place editor is scheduled to consume the E6 transaction model in LE10. | -+| text decoding, locale, width, and byte/text policy | cross-suite framework candidate | current Shared text and related areas | Sed and Ed select different command policies over neutral text primitives. No family layer is needed. | -+| mutable line buffer, stable identities, marks, cut buffer, undo, remembered filename and shell state | Ed-family-specific | `Icod.LineEditor.Ed.Shared` | Required by both Ed and Red. Sed has pattern/hold spaces and cycle state rather than a persistent addressed line buffer. | -+| standard and restricted Ed capabilities, including Red pathname policy | Ed-family-specific | `Icod.LineEditor.Ed.Shared` | `ed --restricted` and `red` are proven consumers of the same immutable profile. Sed sandbox and in-place policies are different. | -+| Ed address parser | Ed-family-specific | `Icod.LineEditor.Ed.Shared` | Coupled to current/last buffer addresses, marks, forward/reverse searches, address arithmetic, and semicolon current-address mutation. | -+| Sed address and range state | Sed-specific | `Icod.LineEditor.Sed` | Coupled to input record number, last-input state, first~step selection, relative range ends, range activation, negation, and command-cycle execution. | -+| Sed program, labels, branches, groups, pattern/hold spaces, and cycle control | Sed-specific | `Icod.LineEditor.Sed` | No Ed/Red consumer exists. These are the defining semantics of the streaming Sed engine. | -+| Sed sandbox and in-place-editing policy | Sed-specific | `Icod.LineEditor.Sed` | Sandbox compile/runtime restrictions and per-file replacement policy do not match Red's pathname-only restricted editor profile. Transaction mechanics move to Shared in LE10, but policy remains Sed-owned. | -+| Ed command-line/session presentation | command-local | `Icod.LineEditor.Ed` | Prompts, byte counts, initial addresses, verbose diagnostics, signal mapping, and executable options belong to the command facade. | -+| Red command-line/session presentation | command-local | `Icod.LineEditor.Red` | The command permanently selects the restricted profile but otherwise remains a thin executable boundary. | -+| Sed command-line and script-source orchestration | command-local/Sed-specific | `Icod.LineEditor.Sed` | Ordered `-e`, `-f`, and implicit scripts, source-relative locations, operand handling, and in-place command policy remain Sed-owned. | -+ -+## Parser comparison -+ -+### Ed -+ -+Ed parses one interactive or scripted command against a persistent mutable -+buffer. Its address grammar depends on: -+ -+- the current and last line addresses; -+- marks that identify stable buffer lines; -+- forward and reverse regular-expression search; -+- relative arithmetic; -+- comma and semicolon ranges, where semicolon changes the search origin; -+- commands that may subsequently mutate the addressed buffer. -+ -+A parser failure is reported as an Ed diagnostic and must preserve the editor -+state required by the command's failure contract. -+ -+### Sed -+ -+Sed compiles a program that is evaluated repeatedly against an input stream. -+Its address and selection model depends on: -+ -+- current input-record number and last-input state; -+- regular-expression addresses evaluated against pattern space; -+- GNU first~step selection; -+- active range state retained by each compiled command; -+- relative and multiple-based range ends; -+- command negation, groups, labels, and branches; -+- the Sed cycle and pattern/hold-space transitions. -+ -+Parser diagnostics retain script-source identity and source-relative line and -+column information. Runtime address state belongs to compiled commands rather -+than to a mutable line-address object. -+ -+### Decision -+ -+Delimiter scanning is embedded in two different grammars and failure models. -+Extracting a common scanner would either expose policy switches for delimiter, -+escape, command-separator, source-span, and end-of-script behavior, or erase -+information required by one consumer. The small lexical overlap does not form -+a stable family API. Ed parsing stays in `Icod.LineEditor.Ed.Shared`; Sed -+parsing stays in `Icod.LineEditor.Sed`. -+ -+## Replacement-template comparison -+ -+Both commands recognize an unescaped `&` as the complete match and support -+numeric capture references, but their complete contracts differ. -+ -+### Ed replacement contract -+ -+- replacement is part of an addressed mutable-buffer command; -+- occurrence selection, global replacement, printing, and error behavior are -+ Ed command flags; -+- replacement updates one or more stored lines and participates in undo and -+ modified-buffer state; -+- diagnostics use the Ed execution result model; -+- the command may reuse remembered substitution state according to Ed rules. -+ -+### Sed replacement contract -+ -+- replacement is a compiled command executed during the streaming cycle; -+- replacement flags interact with automatic printing, explicit printing, -+ branching-on-substitution, and optional file output; -+- escape preprocessing and replacement interpretation are Sed policy; -+- the result updates pattern space rather than a persistent line buffer; -+- diagnostics retain script-source position and Sed command context. -+ -+### Decision -+ -+The Shared regular-expression provider should continue to return matches and -+capture coordinates. It must not own Ed or Sed replacement templates. A common -+replacement tokenizer would require command-specific token meaning, -+source-position, diagnostics, and mutation callbacks and would not be a -+cohesive neutral contract. Each implementation remains local. -+ -+## Consumer evidence for existing Shared APIs -+ -+| Shared area | Ed/Red evidence | Sed evidence | Other-suite direction | -+|---|---|---|---| -+| diagnostics / command context | Ed command hosts the engine through `CommandContext`; engine returns controlled editor results | LE5 made the `CommandContext` overload primary and retained source-aware diagnostics | command framework concern used throughout the repository | -+| records | Ed file and script input use `ByteRecordReader` | LE4 uses Shared LF/NUL framing and explicit termination | Coreutils text commands also require record framing | -+| regular expressions | searches and substitutions use `IRegularExpressionProvider` | LE3 uses the Shared managed GNU BRE/ERE providers | Grep and other regex consumers require the same neutral engine | -+| processes | standard Ed shell capability delegates to `ProcessRunner`; Red supplies a denied capability | Sed shell capability delegates to `ProcessRunner` and sandbox supplies denial | process execution is cross-suite infrastructure | -+| temporary/filesystem | Ed standard file capability uses secure temporary and filesystem operations | Sed's final transaction migration is scheduled for LE10 | Fileutils, Patch, and other mutation commands consume the same durability model | -+| text and locale | Ed selects line-editor policy over byte/text primitives | Sed selects C/POSIX byte or UTF-8 profiles and explicit separators | text semantics are shared by Coreutils, Grep, Diffutils, and Patch | -+ -+These APIs retain the dependency direction: -+ -+```text -+Icod.CoreUtils.Shared incubation project -+Γöé -+Γö£ΓöÇΓöÇ Icod.LineEditor.Sed -+ΓööΓöÇΓöÇ Icod.LineEditor.Ed.Shared -+ Γö£ΓöÇΓöÇ Icod.LineEditor.Ed -+ ΓööΓöÇΓöÇ Icod.LineEditor.Red -+``` -+ -+There is no dependency from Sed to the Ed engine, from the Ed engine to Sed, -+or from either engine to the executable projects. -+ -+## `Icod.LineEditor.Shared` decision -+ -+Do not create `Icod.LineEditor.Shared` at this time. -+ -+After cross-suite responsibilities are assigned to the current Shared -+incubation project, the residual candidates are either Ed-family-specific, -+Sed-specific, or command-local. No cohesive implementation remains with both -+completed engines as consumers. -+ -+The decision may be reopened only when a future change produces at least two -+real consumers of an identical LineEditor-family contract that is not -+appropriate for `Icod.CommandFramework`. A proposal must identify those -+consumers, demonstrate equivalent semantics and diagnostics, and show a -+meaningful dependency reduction before a new project is added. -+ -+## Enforced architecture -+ -+LE9 adds architecture-boundary tests to the Ed.Shared and Sed test projects. -+They verify that: -+ -+- both engines directly consume `Icod.CoreUtils.Shared`; -+- `Icod.LineEditor.Ed.Shared` does not reference Sed or an executable; -+- `Icod.LineEditor.Sed` does not reference the Ed engine or an executable; -+- neither engine references a speculative `Icod.LineEditor.Shared` assembly. -+ -+These tests turn the audit's dependency decision into an executable repository -+constraint while leaving LE10 free to move transaction mechanics into Shared -+without moving command policy. -+ -+## LE10 handoff -+ -+LE10 may move secure sibling-temporary, backup, rollback, metadata, -+symlink/reparse-point, atomic-replacement, and cleanup mechanics into the -+existing Shared transaction boundary. It must preserve: -+ -+- Sed in-place option, suffix, per-file, sandbox, and failure policy in Sed; -+- Ed write, append, force, modified-buffer, and filename policy in the Ed -+ family; -+- the dependency direction recorded above. -diff --git a/docs/history/Icod.LineEditor-Sed-Refactoring-Rationale.md b/docs/history/Icod.LineEditor-Sed-Refactoring-Rationale.md -new file mode 100644 -index 0000000000000000000000000000000000000000..44101d8aa7c5e041c4fe2638aacf059683b32679 ---- /dev/null -+++ b/docs/history/Icod.LineEditor-Sed-Refactoring-Rationale.md -@@ -0,0 +1,690 @@ -+# Icod.LineEditor and Sed Refactoring Rationale -+ -+## Purpose -+ -+The creation of the `Icod.LineEditor` namespace is a substantial architectural change, but the namespace rename itself is not the dangerous part. The real risk is that it exposes older design choices in `sed` that should not simply be carried forward under a new name. -+ -+The present plan deliberately separates: -+ -+```text -+identity and project cleanup -+ Γåô -+behavior-preserving internal decomposition -+ Γåô -+targeted semantic corrections and shared-infrastructure adoption -+``` -+ -+That sequence preserves working behavior while establishing a maintainable architecture for GNU Sed, Ed, and Red. -+ -+## LE0 through LE2 completion notes -+ -+Phase LE0 completed the identity and project-policy cleanup without changing command behavior. The repository now uses the final LineEditor project identities, treats `Icod.LineEditor.Ed.Shared` as the definite Ed/Red engine, keeps a general `Icod.LineEditor.Shared` optional, and records the exact pre-decomposition source and CI baseline in [`Icod.LineEditor-LE0-Baseline.md`](Icod.LineEditor-LE0-Baseline.md). -+ -+Phase LE1 completed the behavior-preserving decomposition described by this rationale. The public `Icod.LineEditor.Sed.Command` boundary and both established entry-point signatures remain unchanged. Its former single-file implementation is now divided into focused partial-class modules for options, scripting, addresses, execution, records, regular expressions, substitution, processes, and files. The decomposition deliberately retains the temporary behaviors assigned to LE3, LE4, LE5, and LE10; characterization and structural tests make those boundaries explicit. -+ -+Phase LE2 completed the Shared regular-expression contract audit. The managed Gate R1 foundation already satisfies the cross-suite GNU Sed and GNU Ed needs for BRE/ERE syntax, leftmost-longest matching, captures, locale injection, authoritative string and byte coordinates, invalid-input policy, diagnostics, cancellation, and resource limits. No production Shared extension was required. The audit deliberately leaves empty-pattern reuse, address/substitution context, match iteration, replacement grammar, output encoding, and Sed diagnostics in `Icod.LineEditor.Sed`; these become the adapter and migration responsibilities of LE3. -+ -+Phase LE3 completed that migration through a private Sed adapter over the Shared managed GNU BRE/ERE providers. Empty-expression reuse, modifiers, GNU escape preprocessing, POSIX interpretation, replacement context, zero-length progression, and diagnostic presentation remain command-owned. -+ -+Phase LE4 completed the record and text-semantic correction. Sed now consumes Shared byte-record framing, preserves CR and explicit final termination, selects C/POSIX byte or UTF-8 decoding from the Shared locale environment, round-trips malformed UTF-8 deterministically, and emits LF or NUL separators explicitly. Pattern and hold spaces remain Sed-owned mutable text states and may grow according to command semantics; unrelated completed records are not retained beyond the one-record lookahead required for `$`. -+ -+Phase LE5 completed the orchestration and side-effect hardening. `CommandContext` is the primary entry path, script inputs retain source identity and LF-only composition, shell and auxiliary-file operations are injectable, sandbox denial exists at compile and runtime layers, Shared `ProcessRunner` remains the host process mechanism, and current in-place replacement is isolated behind `IInPlaceEditor` with secure temporary-object cleanup characterization. Final publication through E6 remains assigned to LE10. -+ -+## 1. The current state is already beyond a raw rename -+ -+The Sed project already uses: -+ -+```text -+Project: Icod.LineEditor.Sed -+Assembly: sed -+Namespace: Icod.LineEditor.Sed -+Command class: Icod.LineEditor.Sed.Command -+``` -+ -+It targets `net10.0`, references the current Shared incubation project, and has already been recognized in the roadmap as structurally migrated. -+ -+The immediate question is therefore not how to rename Sed. It is how to turn the existing implementation into a maintainable `Icod.LineEditor.Sed` engine without breaking behavior that already works. -+ -+Historical Batch 2 should remain complete. The later LineEditor work is a re-audit and architectural modernization, not an erasure of earlier work. -+ -+# Principal areas of concern -+ -+## 2. Before LE1, `Command.cs` owned almost everything -+ -+The pre-LE1 Sed implementation was concentrated in one very large `Command.cs`. It contains: -+ -+- command orchestration; -+- options; -+- script parsing; -+- addresses; -+- instructions; -+- pattern and hold spaces; -+- record reading; -+- regular expressions; -+- substitutions; -+- shell execution; -+- auxiliary file access; -+- in-place editing. -+ -+This has several consequences. -+ -+### Large blast radius -+ -+A change to regex compilation, record framing, or file replacement occurs in the same source unit as the parser and execution state. -+ -+### Poor isolation -+ -+Command-level tests are useful, but they do not make it easy to isolate parser failures, address-state bugs, substitution behavior, sandboxing, or transaction failures. -+ -+### Accidental sharing risk -+ -+When Ed is implemented, it would be easy to move a Sed helper into common code merely because it looks reusable. Similar command syntax does not necessarily imply shared semantics. -+ -+### Completed first step -+ -+LE1 decomposed Sed without changing public behavior: -+ -+```text -+Command -+Γö£ΓöÇΓöÇ Options -+Γö£ΓöÇΓöÇ Scripting -+Γö£ΓöÇΓöÇ Addresses -+Γö£ΓöÇΓöÇ Execution -+Γö£ΓöÇΓöÇ Records -+Γö£ΓöÇΓöÇ RegularExpressions -+Γö£ΓöÇΓöÇ Substitution -+Γö£ΓöÇΓöÇ Files -+ΓööΓöÇΓöÇ Processes -+``` -+ -+The public class remains: -+ -+```text -+Icod.LineEditor.Sed.Command -+``` -+ -+This exposes ownership, enables focused tests, reduces later merge conflicts, and lets sharing decisions be based on cohesive components rather than textual proximity. -+ -+## 3. Do not create `Icod.LineEditor.Shared` too early -+ -+The current Shared project already contains or incubates: -+ -+- argument parsing; -+- diagnostics; -+- delimiter and escape handling; -+- records; -+- text and locale abstractions; -+- regular expressions; -+- process execution; -+- temporary workspaces; -+- filesystem services; -+- platform capability reporting. -+ -+Most of the initially imagined LineEditor-shared features are actually cross-suite: -+ -+```text -+record readers -+regex providers -+source diagnostics -+process launching -+temporary workspaces -+filesystem transactions -+``` -+ -+They are also useful to Grep, Diffutils, Patch, Coreutils, Tar, and other suites. They are therefore better treated as eventual `Icod.CommandFramework` material. -+ -+Creating `Icod.LineEditor.Shared` immediately could produce a redundant layer: -+ -+```text -+Icod.LineEditor.Sed -+ Γåô -+Icod.LineEditor.Shared -+ Γåô -+current Shared -+``` -+ -+without a clear independent responsibility. -+ -+The revised plan therefore makes `Icod.LineEditor.Ed.Shared` definite, because Ed and Red unquestionably share one engine, but makes `Icod.LineEditor.Shared` optional and evidence-based. -+ -+It should be created only when completed Ed and decomposed Sed implementations reveal code that is: -+ -+1. genuinely consumed by both; -+2. not general enough for `Icod.CommandFramework`; -+3. not specific to Ed; -+4. not specific to Sed; -+5. cohesive enough to justify another assembly. -+ -+## 4. Sed and Ed share syntax mechanics, not execution models -+ -+They share concepts such as regular expressions, delimiter-scanned patterns, substitutions, script diagnostics, and file or process effects. -+ -+Their execution models are fundamentally different. -+ -+### Ed -+ -+```text -+persistent mutable line buffer -+current address -+last address -+marks -+cut buffer -+undo -+arbitrary insertion and deletion -+global operations over selected lines -+modified-file state -+``` -+ -+### Sed -+ -+```text -+input record cycle -+pattern space -+hold space -+automatic printing -+append queue -+labels and branches -+per-record address evaluation -+streaming input progression -+``` -+ -+An Ed address identifies a line in a mutable collection. A Sed address is generally a predicate over the current input cycle, possibly with range state across cycles. -+ -+They should not share: -+ -+- one address hierarchy; -+- one command AST; -+- one execution state; -+- one global-command engine; -+- one file-state model; -+- one editor session. -+ -+Only lower-level mechanics should be considered for sharing, such as source spans, delimiter scanning, replacement-template tokenization, and adapters over the common regex engine. -+ -+## 5. The former regular-expression approach was a major concern -+ -+Before LE3, Sed translated GNU-style expressions into .NET regex syntax and invoked `System.Text.RegularExpressions`. LE3 removed that path and now consumes the Shared managed GNU provider through a Sed-specific adapter. -+ -+GNU/POSIX and .NET matching can differ in: -+ -+- leftmost-longest behavior; -+- bracket expressions; -+- locale character classes; -+- back-references; -+- malformed-expression handling; -+- GNU extensions; -+- BRE versus ERE semantics. -+ -+Completion Gate R1 now provides the managed GNU/POSIX BRE and ERE foundation. Phase LE2 verified its syntax, selection, capture, locale, byte/text coordinate, diagnostic, cancellation, and resource contracts against the pinned Sed and Ed baselines. No additional cross-suite production API was required. -+ -+The completed sequence is: -+ -+```text -+Gate R1 Shared BRE/ERE contract (complete and LE2-validated) -+ Γåô -+SedRegularExpressionCompiler owns Sed-specific policy -+ Γåô -+private .NET translation layer removed in LE3 -+``` -+ -+Shared should own: -+ -+- syntax profile; -+- compilation; -+- matching; -+- captures; -+- locale integration; -+- cancellation; -+- diagnostics. -+ -+Sed should own: -+ -+- BRE or ERE selection; -+- empty-pattern reuse; -+- address versus substitution context; -+- option interactions; -+- replacement iteration; -+- Sed-specific diagnostics. -+ -+This is cross-suite work because Grep also needs BRE and ERE. -+ -+## 6. Sed's authoritative model should not be only `string` lines -+ -+The Shared project already has byte-preserving record abstractions that distinguish: -+ -+- record content; -+- separator; -+- terminated versus unterminated final record; -+- LF and NUL framing. -+ -+Sed data semantics require preserving distinctions such as: -+ -+```text -+abc\r\n -+abc\n -+abc\r -+unterminated final record -+invalid UTF-8 -+NUL-delimited records -+embedded newlines in pattern space -+``` -+ -+For Sed: -+ -+```text -+LF is framing data in ordinary mode -+NUL is framing data under -z -+CR is normally data -+``` -+ -+A `TextReader`-only model can erase important distinctions. -+ -+The plan does not require an immediate byte-only rewrite. It introduces a Sed record model that retains: -+ -+```text -+authoritative bytes -+separator kind -+termination state -+source identity -+record number -+optional decoded representation -+byte-to-text mapping -+``` -+ -+The engine may still use text where appropriate, but it no longer loses facts needed for exact output. -+ -+This infrastructure will also benefit Ed without forcing Sed and Ed into one editor engine. -+ -+## 7. Script-source composition must not depend on host newlines -+ -+Sed scripts may come from: -+ -+```text -+-e expression -+-f script file -+implicit first operand -+``` -+ -+Combining fragments with `Environment.NewLine` makes grammar host-dependent. -+ -+Each source should instead be represented explicitly: -+ -+```text -+ScriptSource -+Γö£ΓöÇΓöÇ source kind -+Γö£ΓöÇΓöÇ source name -+Γö£ΓöÇΓöÇ content -+Γö£ΓöÇΓöÇ original line and column information -+ΓööΓöÇΓöÇ synthetic-boundary policy -+``` -+ -+The parser may consume a composite program, while diagnostics still report the correct source. -+ -+Any inserted separator should be an explicit Sed grammar separator, not whichever newline the host uses. -+ -+## 8. In-place editing is a data-integrity boundary -+ -+Sed's current in-place editing already includes useful support for backups, modes, and symlink options, but it still uses command-local replacement mechanics. -+ -+Concerns include: -+ -+- exclusive temporary creation; -+- backup creation; -+- original removal; -+- final installation; -+- metadata restoration; -+- cancellation between stages; -+- flush or write failures; -+- symlink and reparse-point behavior; -+- rollback; -+- orphaned temporary files. -+ -+A sequence such as: -+ -+```text -+move original to backup -+move temporary into place -+``` -+ -+can leave the original pathname absent if the second operation fails. -+ -+The plan therefore: -+ -+1. isolates current behavior behind an internal `InPlaceEditor`; -+2. adds characterization and failure-injection tests; -+3. keeps parser and execution code independent of commit mechanics; -+4. later replaces the implementation with the shared Completion Gate E6 transaction service. -+ -+This avoids creating a permanent Sed-only transaction layer just before a general repository-wide one is scheduled. -+ -+## 9. Sed sandboxing and Red restrictions are related, not identical -+ -+Both restrict dangerous capabilities, but their policies differ. -+ -+### Red -+ -+- denies shell execution; -+- restricts filenames to the permitted current-directory form; -+- otherwise uses the normal Ed engine. -+ -+### Sed sandbox mode -+ -+- denies shell execution; -+- denies external file reads and writes defined by GNU Sed sandbox policy. -+ -+They may share low-level process and filesystem mechanisms, but they should not share one policy object. -+ -+Sed should use defense in depth: -+ -+```text -+compile-time rejection -+ + -+runtime denied capability -+``` -+ -+For example: -+ -+```text -+ISedShellExecutor -+Γö£ΓöÇΓöÇ ProcessRunnerShellExecutor -+ΓööΓöÇΓöÇ DeniedShellExecutor -+``` -+ -+Red should use the same design principle inside `Icod.LineEditor.Ed.Shared`, with Ed-specific file and process policies. -+ -+## 10. Preserve tests before semantic correction -+ -+The existing Sed implementation already has significant behavior and a historical completed batch. -+ -+The refactor should therefore proceed in this order: -+ -+### Characterize -+ -+Add tests for current behavior and edge cases not already covered. -+ -+### Decompose -+ -+Move types into focused modules without changing semantics. -+ -+### Replace one subsystem at a time -+ -+For example: -+ -+```text -+private .NET regex translation -+ Γåô -+Shared BRE and ERE provider -+``` -+ -+Then run the full Sed suite. -+ -+Next: -+ -+```text -+decoded record path -+ Γåô -+byte-preserving record path -+``` -+ -+Then run the full suite. -+ -+Next: -+ -+```text -+command-local replacement -+ Γåô -+shared transaction service -+``` -+ -+This keeps regressions attributable to one change. -+ -+# Why `Icod.LineEditor.Ed.Shared` is different -+ -+## 11. Ed and Red have proven engine-level reuse -+ -+Red is restricted Ed. Both require the same: -+ -+- mutable line buffer; -+- address model; -+- marks; -+- global commands; -+- substitutions; -+- undo; -+- file state; -+- command parser; -+- diagnostics and status model. -+ -+The difference is security profile and executable identity. -+ -+Therefore this is justified immediately: -+ -+```text -+Icod.LineEditor.Ed.Shared -+Γö£ΓöÇΓöÇ complete Ed engine -+Γö£ΓöÇΓöÇ standard security profile -+ΓööΓöÇΓöÇ restricted security profile -+``` -+ -+with thin entry points: -+ -+```text -+Icod.LineEditor.Ed.Command -+Icod.LineEditor.Red.Command -+``` -+ -+# Why the present plan is a good approach -+ -+## 12. It avoids a big-bang rewrite -+ -+A big-bang change would combine: -+ -+- namespace and project changes; -+- parser decomposition; -+- regex replacement; -+- record-model replacement; -+- security changes; -+- in-place editing changes; -+- Ed implementation; -+- Red implementation. -+ -+When tests failed, attribution would be difficult. The phased plan keeps each step reviewable. -+ -+## 13. It follows the repository's incubation philosophy -+ -+The repository is intentionally a multi-suite development workspace. -+ -+The plan keeps: -+ -+- cross-suite regex and record mechanics in the current Shared incubation project; -+- Ed and Red state in `Icod.LineEditor.Ed.Shared`; -+- Sed state in `Icod.LineEditor.Sed`; -+- `Icod.LineEditor.Shared` optional until actual residual reuse is demonstrated. -+ -+This provides evidence for the final package split. -+ -+## 14. It preserves narrow dependency direction -+ -+The desired dependencies are: -+ -+```text -+current Shared incubation project -+ Γåô -+Icod.LineEditor.Sed -+``` -+ -+and: -+ -+```text -+current Shared incubation project -+ Γåô -+Icod.LineEditor.Ed.Shared -+ Γåô -+Icod.LineEditor.Ed -+Icod.LineEditor.Red -+``` -+ -+There is no Sed dependency on the Ed engine, no Ed dependency on Sed, no circular family package, and no duplicate regex or record foundation. -+ -+## 15. It lets code move to the correct eventual owner -+ -+### Likely `Icod.CommandFramework` -+ -+- command contexts; -+- option parser; -+- diagnostics; -+- records; -+- text decoding; -+- GNU regex; -+- process execution; -+- temporary files; -+- filesystem capabilities; -+- transactions. -+ -+### Definitely `Icod.LineEditor.Ed.Shared` -+ -+- mutable Ed buffer; -+- Ed addresses; -+- marks; -+- global-command state; -+- undo; -+- Ed file state; -+- Red restrictions. -+ -+### Definitely Sed-specific -+ -+- pattern space; -+- hold space; -+- Sed range state; -+- labels and branching; -+- cycle control; -+- append queues; -+- Sed sandbox policy; -+- Sed in-place option policy. -+ -+### Possible `Icod.LineEditor.Shared` -+ -+Only when proven: -+ -+- replacement-template lexing; -+- common delimiter scanning; -+- editing-script source diagnostics. -+ -+## 16. It respects the established public names -+ -+The architecture keeps: -+ -+```text -+Icod.LineEditor.Ed.Command -+Icod.LineEditor.Red.Command -+Icod.LineEditor.Sed.Command -+``` -+ -+Supporting types use responsibility-oriented names such as: -+ -+```text -+Icod.LineEditor.Ed.EditorSession -+Icod.LineEditor.Ed.EditorBuffer -+Icod.LineEditor.Sed.ScriptParser -+Icod.LineEditor.Sed.PatternSpace -+Icod.LineEditor.Sed.InPlaceEditor -+``` -+ -+# Recommended practical sequence -+ -+## Stage 1 ΓÇö Repository and characterization cleanup -+ -+- normalize stale project and test names; -+- confirm the Sed baseline; -+- add missing characterization tests; -+- make no major semantic changes. -+ -+## Stage 2 ΓÇö Decompose Sed -+ -+- retain `Icod.LineEditor.Sed.Command`; -+- move implementation into internal modules; -+- preserve behavior. -+ -+## Stage 3 ΓÇö Complete Shared BRE and ERE infrastructure ΓÇö completed -+ -+- Completion Gate R1 extended the Shared regex engine; -+- Phase LE2 added LineEditor-oriented cross-suite acceptance tests; -+- the audit found no need for a duplicate Sed regex engine or another Shared contract. -+ -+## Stage 4 ΓÇö Migrate Sed regular-expression behavior ΓÇö completed -+ -+- replaced the .NET translation layer with `SedRegularExpressionCompiler` over the Shared managed GNU BRE/ERE provider; -+- preserved Sed-specific empty-expression state, modifiers, GNU escape preprocessing, GNU/POSIX policy, match iteration, replacement context, and diagnostics; -+- added GNU sed 4.10 differential coverage for BRE, ERE, captures, locale classes, control and numeric escapes, strict-POSIX bracket behavior, repeated zero-length matches, and leftmost-longest behavior. -+ -+## Stage 5 ΓÇö Correct record and encoding semantics ΓÇö completed -+ -+- `SedInputRecord` preserves authoritative bytes, source identity, aggregate and per-source numbers, separator kind, and final termination; -+- Shared LF/NUL byte-record framing preserves CR and malformed input; -+- the CLI uses raw standard streams and the public text facade uses compatibility adapters; -+- C/POSIX byte and UTF-8 profiles define matching, byte/text mapping, and replacement encoding; -+- LF and NUL are emitted explicitly as Sed data; -+- LF/NUL also select the internal pattern-space separator for `N`, `D`, `P`, `H`, `G`, `W`, and multiline anchors; -+- the Shared regex contract gained only the consumer-proven configurable line separator and NUL-dot options, with source-compatible defaults; -+- CRLF, lone CR, invalid UTF-8, NUL, empty, huge, multiline, hold-space, and unterminated inputs are covered. -+ -+## Stage 6 ΓÇö Harden process, sandbox, and file effects ΓÇö completed -+ -+- added compile-time and runtime sandbox denial capabilities; -+- isolated in-place editing behind `IInPlaceEditor`; -+- preserved Shared `ProcessRunner`; -+- added source-aware script composition, injectable auxiliary files, failure injection, and temporary cleanup coverage. -+ -+## Stage 7 ΓÇö Implement `Icod.LineEditor.Ed.Shared` -+ -+- design the mutable buffer and state machine; -+- consume shared regex, record, process, and filesystem services. -+ -+## Stage 8 ΓÇö Implement Ed -+ -+- use the standard security profile; -+- complete GNU Ed behavior. -+ -+## Stage 9 ΓÇö Implement Red -+ -+- use the same engine; -+- apply restricted file and process capabilities; -+- add adversarial tests. -+ -+## Stage 10 ΓÇö Audit residual sharing -+ -+- compare completed Sed and Ed components; -+- create `Icod.LineEditor.Shared` only if justified. -+ -+## Stage 11 ΓÇö Integrate shared transaction infrastructure -+ -+- migrate Sed in-place editing; -+- migrate Ed write replacement where applicable; -+- test rollback and metadata behavior. -+ -+# Central reasoning -+ -+The plan rests on one principle: -+ -+> Do not decide final library ownership from command names or apparent syntactic similarity. Decide it from completed implementations and real consumers. -+ -+`Icod.LineEditor` is the right namespace family because it gives Ed, Red, and Sed a coherent home and avoids poor namespace and type names. -+ -+Namespace-family membership does not imply one shared engine. -+ -+The architecture therefore distinguishes: -+ -+```text -+common command infrastructure -+ ΓåÆ eventual Icod.CommandFramework -+ -+Ed and Red mutable editor engine -+ ΓåÆ Icod.LineEditor.Ed.Shared -+ -+Sed streaming cycle engine -+ ΓåÆ Icod.LineEditor.Sed -+ -+possible residual family mechanics -+ ΓåÆ optional Icod.LineEditor.Shared -+``` -+ -+This approach minimizes regression risk, avoids duplicate foundations, protects GNU semantics, supports strong Red restrictions, improves Sed's regex and record fidelity, and leaves clean package boundaries for the final repository split. -diff --git a/ed/Icod.LineEditor.Ed.csproj b/ed/Icod.LineEditor.Ed.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..45469fd839f6bbb768a5b9855135bd9444843336 ---- /dev/null -+++ b/ed/Icod.LineEditor.Ed.csproj -@@ -0,0 +1,50 @@ -+ -+ -+ -+ Exe -+ net10.0 -+ 13.0 -+ enable -+ enable -+ true -+ ..\bin\$(Configuration)\ -+ ed -+ Icod.LineEditor.Ed -+ -+ -+ AnyCPU -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/ed/Program.cs b/ed/Program.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..724e831a8e78055b580255f468dadf3a2eac6e59 ---- /dev/null -+++ b/ed/Program.cs -@@ -0,0 +1,32 @@ -+namespace Icod.LineEditor.Ed; -+ -+using Icod.CommandFramework.Diagnostics; -+ -+/// Hosts the asynchronous ed command entry point. -+public static class Program { -+ /// Runs the command with process console streams and cooperative Ctrl+C cancellation. -+ public static async Task Main( -+ string[] args -+ ) { -+ using var cancellationSource = new CancellationTokenSource(); -+ ConsoleCancelEventHandler handler = ( -+ _, -+ eventArgs -+ ) => { -+ eventArgs.Cancel = true; -+ cancellationSource.Cancel(); -+ }; -+ Console.CancelKeyPress += handler; -+ try { -+ return await Command.RunAsync( -+ args, -+ CommandContext.CreateConsole( -+ "ed", -+ cancellationSource.Token -+ ) -+ ).ConfigureAwait( false ); -+ } finally { -+ Console.CancelKeyPress -= handler; -+ } -+ } -+} -diff --git a/ed/README.md b/ed/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..7865978babd79d174b94b0223fa6972750c6d0ef ---- /dev/null -+++ b/ed/README.md -@@ -0,0 +1,18 @@ -+# Icod.LineEditor.Ed -+ -+`Icod.LineEditor.Ed` is the standard-profile command-line host for the reusable -+`Icod.LineEditor.Ed.Shared` mutable line-editor engine. The executable retains -+the lowercase assembly name `ed`, the public `Icod.LineEditor.Ed.Command` -+facade, and the repository's synchronous and cancellation-aware asynchronous -+entry contracts. -+ -+The command layer owns GNU ed 1.22.5 invocation policy, option parsing, -+initial-file loading, initial-address selection, prompting, quiet and verbose -+presentation, exit-status mapping, and composition of standard or restricted -+file/process capabilities. Address parsing, mutable buffer operations, regular -+expressions, substitutions, global commands, undo, file mutation, filters, and -+controlled engine diagnostics remain in `Icod.LineEditor.Ed.Shared`. -+ -+The binary `CommandContext` streams are authoritative whenever available. -+Text-only compatibility overloads bridge through UTF-8 without taking -+ownership of caller-supplied readers or writers. -diff --git a/ed/src/Command.cs b/ed/src/Command.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..d7fdbca761b96220550c1ad0dca4beb6cf5c4d0f ---- /dev/null -+++ b/ed/src/Command.cs -@@ -0,0 +1,901 @@ -+namespace Icod.LineEditor.Ed; -+ -+using System.Globalization; -+using System.Text; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.Records; -+using Icod.CommandFramework.RegularExpressions; -+ -+/// -+/// Implements the GNU-compatible line editor over . -+/// Usage: ed [OPTION]... [[+LINE] FILE]. -+/// -+public static class Command { -+ private const string ProgramName = "ed"; -+ private const string Version = "ed (Icod.CoreUtils) 1.0; GNU ed 1.22.5 compatibility profile"; -+ private static readonly ReadOnlyMemory LineFeed = new byte[] { (byte)'\n' }; -+ -+ /// Runs the command synchronously for compatibility. -+ public static int Run( -+ string[] args, -+ TextReader? stdin = null, -+ TextWriter? stdout = null, -+ TextWriter? stderr = null -+ ) => RunAsync( -+ args, -+ stdin, -+ stdout, -+ stderr -+ ).GetAwaiter().GetResult(); -+ -+ /// Runs the command asynchronously with injectable text streams. -+ public static Task RunAsync( -+ string[] args, -+ TextReader? stdin = null, -+ TextWriter? stdout = null, -+ TextWriter? stderr = null, -+ CancellationToken cancellationToken = default -+ ) => RunAsync( -+ args ?? [], -+ new CommandContext( -+ ProgramName, -+ stdin ?? Console.In, -+ stdout ?? Console.Out, -+ stderr ?? Console.Error, -+ cancellationToken: cancellationToken -+ ) -+ ); -+ -+ /// Runs the command asynchronously with byte-preserving streams. -+ public static Task RunAsync( -+ string[] args, -+ Stream standardInput, -+ Stream standardOutput, -+ Stream standardError, -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentNullException.ThrowIfNull( standardInput ); -+ ArgumentNullException.ThrowIfNull( standardOutput ); -+ ArgumentNullException.ThrowIfNull( standardError ); -+ return RunCoreAsync( -+ args ?? [], -+ standardInput, -+ standardOutput, -+ standardError, -+ isInteractive: false, -+ cancellationToken -+ ); -+ } -+ -+ /// Runs the command asynchronously with a complete command context. -+ public static async Task RunAsync( -+ string[] args, -+ CommandContext context -+ ) { -+ ArgumentNullException.ThrowIfNull( context ); -+ if ( -+ null != context.StandardInputStream -+ && null != context.StandardOutputStream -+ && null != context.StandardErrorStream -+ ) { -+ return await RunCoreAsync( -+ args ?? [], -+ context.StandardInputStream, -+ context.StandardOutputStream, -+ context.StandardErrorStream, -+ isInteractive: ReferenceEquals( context.StandardInput, Console.In ) && !Console.IsInputRedirected, -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ var inputText = await context.StandardInput.ReadToEndAsync( -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ await using var input = new MemoryStream( Encoding.UTF8.GetBytes( inputText ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ var status = await RunCoreAsync( -+ args ?? [], -+ input, -+ output, -+ error, -+ isInteractive: false, -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ await context.StandardOutput.WriteAsync( -+ Encoding.UTF8.GetString( output.ToArray() ).AsMemory(), -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ await context.StandardError.WriteAsync( -+ Encoding.UTF8.GetString( error.ToArray() ).AsMemory(), -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ return status; -+ } -+ -+ /// Writes the complete command usage and option reference. -+ public static async Task WriteUsageAsync( -+ CommandContext context -+ ) { -+ ArgumentNullException.ThrowIfNull( context ); -+ const string usage = """ -+Usage: ed [OPTION]... [[+LINE] FILE] -+Edit text line by line. -+ -+ -E, --extended-regexp use extended regular expressions -+ -G, --traditional run in traditional compatibility mode -+ -l, --loose-exit-status exit successfully after command errors -+ -p, --prompt=STRING use STRING as the command prompt -+ -q, --quiet, --silent suppress diagnostic messages -+ -r, --restricted restrict filenames and disable shell commands -+ -s, --script suppress byte counts and shell completion prompts -+ -v, --verbose print diagnostic explanations -+ --strip-trailing-cr remove a trailing CR from each input record -+ --unsafe-names permit control characters in filenames -+ -h, --help display this help and exit -+ -V, --version output version information and exit -+ -+LINE may be a line number, '+', '/REGEXP/', or '?REGEXP?'. -+Commands and edited records are LF-delimited data; CRLF command input is accepted. -+"""; -+ await context.StandardOutput.WriteAsync( -+ usage.AsMemory(), -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ private static async Task RunCoreAsync( -+ string[] args, -+ Stream standardInput, -+ Stream standardOutput, -+ Stream standardError, -+ bool isInteractive, -+ CancellationToken cancellationToken -+ ) { -+ var quietDiagnostics = false; -+ try { -+ var parser = CreateParser(); -+ var parsed = parser.Parse( args ); -+ if ( !parsed.IsSuccess ) { -+ foreach ( var error in parsed.Errors ) { -+ await WriteTextLineAsync( -+ standardError, -+ OptionDiagnosticFormatter.Format( ProgramName, error ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return 1; -+ } -+ if ( parsed.HasOption( "help" ) ) { -+ await WriteUsageAsync( standardOutput, cancellationToken ).ConfigureAwait( false ); -+ return 0; -+ } -+ if ( parsed.HasOption( "version" ) ) { -+ await WriteTextLineAsync( standardOutput, Version, cancellationToken ).ConfigureAwait( false ); -+ return 0; -+ } -+ -+ var options = EdOptions.From( parsed ); -+ quietDiagnostics = options.QuietDiagnostics; -+ if ( !TryCreateInvocation( parsed.Operands, out var invocation, out var invocationError ) ) { -+ if ( !quietDiagnostics ) { -+ await WriteDiagnosticAsync( standardError, invocationError!, cancellationToken ).ConfigureAwait( false ); -+ } -+ return 1; -+ } -+ if ( -+ null != invocation.FileName -+ && !invocation.FileName.StartsWith( '!' ) -+ && !IsAllowedFileName( invocation.FileName, options.UnsafeNames ) -+ ) { -+ if ( !quietDiagnostics ) { -+ await WriteDiagnosticAsync( -+ standardError, -+ "filename contains a disallowed control character", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return 1; -+ } -+ -+ IEditorFileAccess fileAccess = new StandardEditorFileAccess(); -+ if ( options.StripTrailingCarriageReturn ) { -+ fileAccess = new CarriageReturnStrippingFileAccess( fileAccess ); -+ } -+ fileAccess = new FileNamePolicyEditorFileAccess( -+ fileAccess, -+ options.UnsafeNames -+ ); -+ var workingDirectory = Directory.GetCurrentDirectory(); -+ var profile = options.Restricted -+ ? EditorCapabilityProfile.Restricted( workingDirectory, fileAccess ) -+ : EditorCapabilityProfile.Standard( fileAccess, new StandardEditorProcessAccess() ); -+ fileAccess = profile.FileAccess; -+ var processAccess = profile.ProcessAccess; -+ var expressionProvider = options.ExtendedRegularExpressions -+ ? (IRegularExpressionProvider)GnuExtendedRegularExpressionProvider.Default -+ : GnuBasicRegularExpressionProvider.Default; -+ var engine = new EditorEngine( profile, expressionProvider ); -+ -+ var initialFileError = await LoadInitialFileAsync( -+ engine, -+ fileAccess, -+ processAccess, -+ invocation, -+ options, -+ standardOutput, -+ standardError, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( null != invocation.InitialAddress ) { -+ if ( !await TryApplyInitialAddressAsync( -+ engine, -+ invocation.InitialAddress, -+ standardError, -+ options.QuietDiagnostics, -+ cancellationToken -+ ).ConfigureAwait( false ) ) { -+ return 1; -+ } -+ } -+ -+ var sessionStatus = await RunSessionAsync( -+ engine, -+ standardInput, -+ standardOutput, -+ standardError, -+ options, -+ isInteractive, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( 0 != sessionStatus ) { -+ return sessionStatus; -+ } -+ return initialFileError && !isInteractive ? 2 : 0; -+ } catch ( OperationCanceledException ) { -+ return 2; -+ } catch ( IOException exception ) { -+ if ( !quietDiagnostics ) { -+ try { -+ await WriteDiagnosticAsync( standardError, exception.Message, CancellationToken.None ).ConfigureAwait( false ); -+ } catch ( IOException ) { -+ } -+ } -+ return 1; -+ } catch ( Exception exception ) when ( -+ exception is UnauthorizedAccessException -+ or NotSupportedException -+ or ArgumentException -+ or InvalidOperationException -+ ) { -+ if ( !quietDiagnostics ) { -+ await WriteDiagnosticAsync( standardError, exception.Message, cancellationToken ).ConfigureAwait( false ); -+ } -+ return 1; -+ } catch ( Exception exception ) { -+ if ( !quietDiagnostics ) { -+ try { -+ await WriteDiagnosticAsync( -+ standardError, -+ string.Concat( "internal editor failure: ", exception.Message ), -+ CancellationToken.None -+ ).ConfigureAwait( false ); -+ } catch ( IOException ) { -+ } -+ } -+ return 3; -+ } -+ } -+ -+ private static async Task LoadInitialFileAsync( -+ EditorEngine engine, -+ IEditorFileAccess fileAccess, -+ IEditorProcessAccess processAccess, -+ EdInvocation invocation, -+ EdOptions options, -+ Stream standardOutput, -+ Stream standardError, -+ CancellationToken cancellationToken -+ ) { -+ if ( null == invocation.FileName ) { -+ engine.Load( [] ); -+ return false; -+ } -+ var fileName = invocation.FileName; -+ try { -+ EditorFileReadResult read; -+ if ( fileName.StartsWith( '!' ) ) { -+ if ( options.Restricted ) { -+ throw new UnauthorizedAccessException( "Shell input is disabled in restricted mode." ); -+ } -+ var process = await processAccess.RunShellAsync( -+ fileName[ 1.. ], -+ ReadOnlyMemory.Empty, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await standardError.WriteAsync( process.StandardError, cancellationToken ).ConfigureAwait( false ); -+ if ( process.Canceled ) { -+ throw new OperationCanceledException( cancellationToken ); -+ } -+ if ( 0 != ( process.ExitCode ?? 1 ) ) { -+ throw new IOException( "The initial shell command failed." ); -+ } -+ read = await ReadRecordsAsync( -+ process.StandardOutput, -+ options.StripTrailingCarriageReturn, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ engine.Load( read.Lines, read.FinalRecordTerminated ); -+ } else { -+ read = await fileAccess.ReadAsync( fileName, cancellationToken ).ConfigureAwait( false ); -+ engine.Load( read.Lines, read.FinalRecordTerminated, fileName ); -+ } -+ if ( !options.ScriptMode ) { -+ await WriteTextLineAsync( -+ standardOutput, -+ read.ByteCount.ToString( CultureInfo.InvariantCulture ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return false; -+ } catch ( FileNotFoundException ) { -+ engine.Load( [], rememberedFileName: fileName ); -+ if ( !options.QuietDiagnostics ) { -+ await WriteDiagnosticAsync( -+ standardError, -+ string.Concat( fileName, ": No such file or directory" ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return true; -+ } catch ( DirectoryNotFoundException ) { -+ engine.Load( [], rememberedFileName: fileName ); -+ if ( !options.QuietDiagnostics ) { -+ await WriteDiagnosticAsync( -+ standardError, -+ string.Concat( fileName, ": No such file or directory" ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return true; -+ } -+ } -+ -+ private static async Task RunSessionAsync( -+ EditorEngine engine, -+ Stream standardInput, -+ Stream standardOutput, -+ Stream standardError, -+ EdOptions options, -+ bool isInteractive, -+ CancellationToken cancellationToken -+ ) { -+ using var reader = new ByteRecordReader( standardInput ); -+ var verbose = options.Verbose; -+ var prompt = options.Prompt; -+ var modifiedQuitWarning = false; -+ var modifiedEditWarning = false; -+ var hadError = false; -+ while ( true ) { -+ if ( null != prompt ) { -+ await standardOutput.WriteAsync( Encoding.UTF8.GetBytes( prompt ), cancellationToken ).ConfigureAwait( false ); -+ await standardOutput.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ var record = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == record ) { -+ break; -+ } -+ var commandBytes = NormalizeRecord( record.Content, record.IsTerminated ); -+ var commandText = Encoding.UTF8.GetString( commandBytes.Span ); -+ var commandCharacter = FindCommandCharacter( commandText ); -+ if ( 'H' == commandCharacter && "H" == commandText.Trim() ) { -+ verbose = !verbose; -+ continue; -+ } -+ if ( 'P' == commandCharacter && "P" == commandText.Trim() ) { -+ prompt = null == prompt ? "*" : null; -+ continue; -+ } -+ if ( 'q' == commandCharacter && modifiedQuitWarning && "q" == commandText.Trim() ) { -+ commandText = "Q"; -+ commandBytes = Encoding.UTF8.GetBytes( commandText ); -+ commandCharacter = 'Q'; -+ } -+ if ( 'e' == commandCharacter && modifiedEditWarning ) { -+ commandText = ReplaceCommandCharacter( commandText, 'E' ); -+ commandBytes = Encoding.UTF8.GetBytes( commandText ); -+ commandCharacter = 'E'; -+ } -+ -+ await using var commandStream = new MemoryStream(); -+ await commandStream.WriteAsync( commandBytes, cancellationToken ).ConfigureAwait( false ); -+ await commandStream.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ if ( commandCharacter is 'a' or 'i' or 'c' ) { -+ var terminated = false; -+ while ( true ) { -+ var data = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == data ) { -+ break; -+ } -+ var dataBytes = NormalizeRecord( data.Content, data.IsTerminated ); -+ await commandStream.WriteAsync( dataBytes, cancellationToken ).ConfigureAwait( false ); -+ await commandStream.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ if ( dataBytes.Span.SequenceEqual( new byte[] { (byte)'.' } ) ) { -+ terminated = true; -+ break; -+ } -+ } -+ if ( !terminated ) { -+ if ( !options.QuietDiagnostics ) { -+ await WriteQuestionAsync( standardError, cancellationToken ).ConfigureAwait( false ); -+ } -+ if ( verbose && !options.QuietDiagnostics ) { -+ await WriteTextLineAsync( -+ standardError, -+ "The command data block is not terminated by a single period.", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return options.LooseExitStatus ? 0 : 1; -+ } -+ } -+ commandStream.Position = 0; -+ -+ var suppressInformational = options.ScriptMode && ( commandCharacter is 'e' or 'E' or 'r' or 'w' or 'W' ); -+ using var discardedOutput = suppressInformational ? new MemoryStream() : null; -+ var commandOutput = suppressInformational ? discardedOutput! : standardOutput; -+ await using var commandError = new MemoryStream(); -+ var result = await engine.ExecuteScriptAsync( -+ commandStream, -+ commandOutput, -+ commandError, -+ "", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await ForwardEngineErrorAsync( -+ commandCharacter, -+ result, -+ commandError.ToArray(), -+ standardOutput, -+ standardError, -+ options.QuietDiagnostics, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( result.IsSuccess ) { -+ modifiedQuitWarning = false; -+ modifiedEditWarning = false; -+ if ( result.QuitRequested ) { -+ return options.LooseExitStatus ? 0 : hadError ? 1 : 0; -+ } -+ continue; -+ } -+ hadError = true; -+ if ( EditorDiagnosticCode.ModifiedBuffer == result.Diagnostic?.Code ) { -+ if ( 'q' == commandCharacter ) { -+ modifiedQuitWarning = true; -+ } -+ if ( 'e' == commandCharacter ) { -+ modifiedEditWarning = true; -+ } -+ } -+ if ( verbose && !options.QuietDiagnostics && null != result.Diagnostic ) { -+ await WriteTextLineAsync( -+ standardError, -+ result.Diagnostic.Message, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ if ( EditorExitStatus.Interrupted == result.ExitStatus ) { -+ return 2; -+ } -+ if ( !isInteractive && !options.LooseExitStatus ) { -+ return EditorDiagnosticCode.ModifiedBuffer == result.Diagnostic?.Code ? 2 : 1; -+ } -+ } -+ return options.LooseExitStatus ? 0 : hadError ? 1 : 0; -+ } -+ -+ private static async ValueTask ForwardEngineErrorAsync( -+ char commandCharacter, -+ EditorExecutionResult result, -+ ReadOnlyMemory bytes, -+ Stream standardOutput, -+ Stream standardError, -+ bool quietDiagnostics, -+ CancellationToken cancellationToken -+ ) { -+ if ( bytes.IsEmpty ) { -+ return; -+ } -+ if ( result.IsSuccess && 'h' == commandCharacter ) { -+ await standardOutput.WriteAsync( bytes, cancellationToken ).ConfigureAwait( false ); -+ return; -+ } -+ var content = bytes; -+ if ( -+ quietDiagnostics -+ && !result.IsSuccess -+ && 2 <= content.Length -+ && (byte)'?' == content.Span[ ^2 ] -+ && (byte)'\n' == content.Span[ ^1 ] -+ ) { -+ content = content[ ..^2 ]; -+ } -+ if ( !content.IsEmpty ) { -+ await standardError.WriteAsync( content, cancellationToken ).ConfigureAwait( false ); -+ } -+ } -+ -+ private static async Task TryApplyInitialAddressAsync( -+ EditorEngine engine, -+ string initialAddress, -+ Stream standardError, -+ bool quietDiagnostics, -+ CancellationToken cancellationToken -+ ) { -+ if ( "+" == initialAddress ) { -+ engine.SetCurrentAddress( engine.Buffer.Count ); -+ return true; -+ } -+ var text = initialAddress[ 1.. ]; -+ if ( int.TryParse( text, NumberStyles.None, CultureInfo.InvariantCulture, out var address ) ) { -+ try { -+ engine.SetCurrentAddress( Math.Min( address, engine.Buffer.Count ) ); -+ return true; -+ } catch ( ArgumentOutOfRangeException ) { -+ if ( !quietDiagnostics ) { -+ await WriteQuestionAsync( standardError, cancellationToken ).ConfigureAwait( false ); -+ } -+ return false; -+ } -+ } -+ if ( text.StartsWith( '/' ) && !text.EndsWith( '/' ) ) { -+ text = string.Concat( text, "/" ); -+ } else if ( text.StartsWith( '?' ) && !text.EndsWith( '?' ) ) { -+ text = string.Concat( text, "?" ); -+ } -+ await using var script = new MemoryStream( Encoding.UTF8.GetBytes( string.Concat( text, "=\n" ) ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ var result = await engine.ExecuteScriptAsync( -+ script, -+ output, -+ error, -+ "", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( !result.IsSuccess ) { -+ if ( !quietDiagnostics ) { -+ await standardError.WriteAsync( error.ToArray(), cancellationToken ).ConfigureAwait( false ); -+ } -+ return false; -+ } -+ var value = Encoding.UTF8.GetString( output.ToArray() ).Trim(); -+ if ( !int.TryParse( value, NumberStyles.None, CultureInfo.InvariantCulture, out address ) ) { -+ if ( !quietDiagnostics ) { -+ await WriteQuestionAsync( standardError, cancellationToken ).ConfigureAwait( false ); -+ } -+ return false; -+ } -+ engine.SetCurrentAddress( address ); -+ return true; -+ } -+ -+ private static bool TryCreateInvocation( -+ IReadOnlyList operands, -+ out EdInvocation invocation, -+ out string? error -+ ) { -+ string? initialAddress = null; -+ string? fileName = null; -+ var index = 0; -+ if ( 0 < operands.Count && operands[ 0 ].StartsWith( '+') ) { -+ initialAddress = operands[ 0 ]; -+ index++; -+ } -+ if ( index < operands.Count ) { -+ fileName = operands[ index++ ]; -+ } -+ if ( index != operands.Count ) { -+ invocation = default!; -+ error = "too many file operands"; -+ return false; -+ } -+ invocation = new EdInvocation( initialAddress, fileName ); -+ error = null; -+ return true; -+ } -+ -+ private static OptionParser CreateParser() => new( -+ [ -+ new OptionDefinition( "extended", 'E', [ "extended-regexp" ], allowMultiple: false ), -+ new OptionDefinition( "traditional", 'G', [ "traditional" ], allowMultiple: false ), -+ new OptionDefinition( "loose", 'l', [ "loose-exit-status" ], allowMultiple: false ), -+ new OptionDefinition( "prompt", 'p', [ "prompt" ], OptionValueArity.Required, allowMultiple: false ), -+ new OptionDefinition( "quiet", 'q', [ "quiet", "silent" ], allowMultiple: false ), -+ new OptionDefinition( "restricted", 'r', [ "restricted" ], allowMultiple: false ), -+ new OptionDefinition( "script", 's', [ "script" ], allowMultiple: false ), -+ new OptionDefinition( "verbose", 'v', [ "verbose" ], allowMultiple: false ), -+ new OptionDefinition( "strip-cr", null, [ "strip-trailing-cr" ], allowMultiple: false ), -+ new OptionDefinition( "unsafe-names", null, [ "unsafe-names" ], allowMultiple: false ), -+ new OptionDefinition( "help", 'h', [ "help" ], allowMultiple: false ), -+ new OptionDefinition( "version", 'V', [ "version" ], allowMultiple: false ), -+ ], -+ new OptionParserSettings { -+ AllowLongOptionAbbreviations = true, -+ Ordering = OptionOrdering.Permute, -+ } -+ ); -+ -+ private static char FindCommandCharacter( -+ string text -+ ) { -+ var index = FindCommandIndex( text ); -+ return 0 > index ? '\0' : text[ index ]; -+ } -+ -+ private static int FindCommandIndex( -+ string text -+ ) { -+ var escaped = false; -+ var delimiter = '\0'; -+ var afterMark = false; -+ for ( var index = 0; text.Length > index; index++ ) { -+ var character = text[ index ]; -+ if ( '\0' != delimiter ) { -+ if ( escaped ) { -+ escaped = false; -+ continue; -+ } -+ if ( '\\' == character ) { -+ escaped = true; -+ continue; -+ } -+ if ( delimiter == character ) { -+ delimiter = '\0'; -+ } -+ continue; -+ } -+ if ( afterMark ) { -+ afterMark = false; -+ continue; -+ } -+ if ( '\'' == character ) { -+ afterMark = true; -+ continue; -+ } -+ if ( character is '/' or '?' ) { -+ delimiter = character; -+ continue; -+ } -+ if ( char.IsLetter( character ) || character is '!' or '=' or '#' ) { -+ return index; -+ } -+ } -+ return -1; -+ } -+ -+ private static string ReplaceCommandCharacter( -+ string text, -+ char replacement -+ ) { -+ var index = FindCommandIndex( text ); -+ if ( 0 > index ) { -+ return text; -+ } -+ return string.Concat( text.Substring( 0, index ), replacement.ToString(), text.Substring( index + 1 ) ); -+ } -+ -+ private static ReadOnlyMemory NormalizeRecord( -+ ReadOnlyMemory content, -+ bool terminated -+ ) { -+ if ( terminated && !content.IsEmpty && (byte)'\r' == content.Span[ ^1 ] ) { -+ return content[ ..^1 ].ToArray(); -+ } -+ return content.ToArray(); -+ } -+ -+ private static bool IsAllowedFileName( -+ string fileName, -+ bool allowUnsafeNames -+ ) { -+ if ( fileName.Any( character => character is '\0' or '\n' ) ) { -+ return false; -+ } -+ if ( allowUnsafeNames ) { -+ return true; -+ } -+ return fileName.All( -+ character => character is not ( '\a' or '\b' or '\t' or '\v' or '\f' or '\r' or '\u001B' or '\u007F' ) -+ ); -+ } -+ -+ private static async ValueTask ReadRecordsAsync( -+ ReadOnlyMemory bytes, -+ bool stripTrailingCarriageReturn, -+ CancellationToken cancellationToken -+ ) { -+ await using var stream = new MemoryStream( bytes.ToArray(), writable: false ); -+ using var reader = new ByteRecordReader( stream ); -+ var lines = new List>(); -+ var finalTerminated = true; -+ while ( true ) { -+ var record = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == record ) { -+ break; -+ } -+ var content = record.Content; -+ if ( stripTrailingCarriageReturn && record.IsTerminated && !content.IsEmpty && (byte)'\r' == content.Span[ ^1 ] ) { -+ content = content[ ..^1 ]; -+ } -+ lines.Add( content.ToArray() ); -+ finalTerminated = record.IsTerminated; -+ } -+ return new EditorFileReadResult( -+ lines.AsReadOnly(), -+ 0 == lines.Count || finalTerminated, -+ bytes.Length -+ ); -+ } -+ -+ private static async ValueTask WriteUsageAsync( -+ Stream output, -+ CancellationToken cancellationToken -+ ) { -+ const string usage = """ -+Usage: ed [OPTION]... [[+LINE] FILE] -+Try 'ed --help' for more information. -+"""; -+ await output.WriteAsync( Encoding.UTF8.GetBytes( usage ), cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private static ValueTask WriteDiagnosticAsync( -+ Stream error, -+ string message, -+ CancellationToken cancellationToken -+ ) => WriteTextLineAsync( -+ error, -+ string.Concat( ProgramName, ": ", message ), -+ cancellationToken -+ ); -+ -+ private static async ValueTask WriteQuestionAsync( -+ Stream error, -+ CancellationToken cancellationToken -+ ) { -+ await error.WriteAsync( new byte[] { (byte)'?', (byte)'\n' }, cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private static async ValueTask WriteTextLineAsync( -+ Stream output, -+ string text, -+ CancellationToken cancellationToken -+ ) { -+ await output.WriteAsync( Encoding.UTF8.GetBytes( text ), cancellationToken ).ConfigureAwait( false ); -+ await output.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private sealed record EdInvocation( -+ string? InitialAddress, -+ string? FileName -+ ); -+ -+ private sealed record EdOptions( -+ bool ExtendedRegularExpressions, -+ bool Traditional, -+ bool LooseExitStatus, -+ string? Prompt, -+ bool QuietDiagnostics, -+ bool ScriptMode, -+ bool Restricted, -+ bool Verbose, -+ bool StripTrailingCarriageReturn, -+ bool UnsafeNames -+ ) { -+ public static EdOptions From( -+ OptionParseResult result -+ ) => new( -+ result.HasOption( "extended" ), -+ result.HasOption( "traditional" ), -+ result.HasOption( "loose" ), -+ result.GetLastValue( "prompt" ), -+ result.HasOption( "quiet" ), -+ result.HasOption( "script" ), -+ result.HasOption( "restricted" ), -+ result.HasOption( "verbose" ), -+ result.HasOption( "strip-cr" ), -+ result.HasOption( "unsafe-names" ) -+ ); -+ } -+ -+ private sealed class FileNamePolicyEditorFileAccess : IEditorFileAccess { -+ private readonly IEditorFileAccess inner; -+ private readonly bool allowUnsafeNames; -+ -+ public FileNamePolicyEditorFileAccess( -+ IEditorFileAccess inner, -+ bool allowUnsafeNames -+ ) { -+ ArgumentNullException.ThrowIfNull( inner ); -+ this.inner = inner; -+ this.allowUnsafeNames = allowUnsafeNames; -+ } -+ -+ public ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ this.Validate( path ); -+ return this.inner.ReadAsync( path, cancellationToken ); -+ } -+ -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) { -+ this.Validate( path ); -+ return this.inner.WriteAsync( -+ path, -+ lines, -+ append, -+ terminateFinalRecord, -+ cancellationToken -+ ); -+ } -+ -+ private void Validate( -+ string path -+ ) { -+ if ( !IsAllowedFileName( path, this.allowUnsafeNames ) ) { -+ throw new UnauthorizedAccessException( "The filename contains a disallowed control character." ); -+ } -+ } -+ } -+ -+ private sealed class CarriageReturnStrippingFileAccess : IEditorFileAccess { -+ private readonly IEditorFileAccess inner; -+ -+ public CarriageReturnStrippingFileAccess( -+ IEditorFileAccess inner -+ ) { -+ ArgumentNullException.ThrowIfNull( inner ); -+ this.inner = inner; -+ } -+ -+ public async ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ var result = await this.inner.ReadAsync( path, cancellationToken ).ConfigureAwait( false ); -+ return result with { -+ Lines = result.Lines.Select( -+ ( line, index ) => -+ !line.IsEmpty -+ && (byte)'\r' == line.Span[ ^1 ] -+ && ( result.FinalRecordTerminated || result.Lines.Count - 1 != index ) -+ ? new ReadOnlyMemory( line[ ..^1 ].ToArray() ) -+ : new ReadOnlyMemory( line.ToArray() ) -+ ).ToArray() -+ }; -+ } -+ -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) => this.inner.WriteAsync( -+ path, -+ lines, -+ append, -+ terminateFinalRecord, -+ cancellationToken -+ ); -+ } -+} -diff --git a/red/Icod.LineEditor.Red.csproj b/red/Icod.LineEditor.Red.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..bd8595ccc0b1657497ab657b91b254ed28c54633 ---- /dev/null -+++ b/red/Icod.LineEditor.Red.csproj -@@ -0,0 +1,50 @@ -+ -+ -+ -+ Exe -+ net10.0 -+ 13.0 -+ enable -+ enable -+ true -+ ..\bin\$(Configuration)\ -+ red -+ Icod.LineEditor.Red -+ -+ -+ AnyCPU -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/red/Program.cs b/red/Program.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..2931a3e88667a437458a29fc5781e7398c500b84 ---- /dev/null -+++ b/red/Program.cs -@@ -0,0 +1,32 @@ -+namespace Icod.LineEditor.Red; -+ -+using Icod.CommandFramework.Diagnostics; -+ -+/// Hosts the asynchronous red command entry point. -+public static class Program { -+ /// Runs the command with process console streams and cooperative Ctrl+C cancellation. -+ public static async Task Main( -+ string[] args -+ ) { -+ using var cancellationSource = new CancellationTokenSource(); -+ ConsoleCancelEventHandler handler = ( -+ _, -+ eventArgs -+ ) => { -+ eventArgs.Cancel = true; -+ cancellationSource.Cancel(); -+ }; -+ Console.CancelKeyPress += handler; -+ try { -+ return await Command.RunAsync( -+ args, -+ CommandContext.CreateConsole( -+ "red", -+ cancellationSource.Token -+ ) -+ ).ConfigureAwait( false ); -+ } finally { -+ Console.CancelKeyPress -= handler; -+ } -+ } -+} -diff --git a/red/README.md b/red/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..6a35578cbca9ac4e9188659bf1ab219afc2d9c82 ---- /dev/null -+++ b/red/README.md -@@ -0,0 +1,7 @@ -+# Icod.LineEditor.Red -+ -+`red` is the permanently restricted GNU-compatible line-editor command. -+ -+The executable retains the public `Icod.LineEditor.Red.Command` facade and lowercase `red` assembly while delegating mutable editor behavior to `Icod.LineEditor.Ed.Shared`. It always selects the same immutable restricted capability profile used by `ed --restricted`; `-r` is accepted only as compatibility syntax. -+ -+Restricted mode denies shell commands before address resolution or mutable dispatch and supplies a denied process capability as defense in depth. Every filename-bearing operation passes through one captured-working-directory pathname policy. That policy permits only simple leaf names and deliberately promises pathname restriction, not physical confinement across symbolic links, hard links, mount points, reparse points, or validation/open races. -diff --git a/red/src/Command.cs b/red/src/Command.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..2fc1ee8379b0607a52f6792822471eeca4532d74 ---- /dev/null -+++ b/red/src/Command.cs -@@ -0,0 +1,848 @@ -+namespace Icod.LineEditor.Red; -+ -+using Icod.LineEditor.Ed; -+ -+using System.Globalization; -+using System.Text; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.Records; -+using Icod.CommandFramework.RegularExpressions; -+ -+/// -+/// Implements the GNU-compatible restricted line editor over . -+/// Usage: red [OPTION]... [[+LINE] FILE]. -+/// -+public static class Command { -+ private const string ProgramName = "red"; -+ private const string Version = "red (Icod.CoreUtils) 1.0; GNU ed 1.22.5 restricted compatibility profile"; -+ private static readonly ReadOnlyMemory LineFeed = new byte[] { (byte)'\n' }; -+ -+ /// Runs the command synchronously for compatibility. -+ public static int Run( -+ string[] args, -+ TextReader? stdin = null, -+ TextWriter? stdout = null, -+ TextWriter? stderr = null -+ ) => RunAsync( -+ args, -+ stdin, -+ stdout, -+ stderr -+ ).GetAwaiter().GetResult(); -+ -+ /// Runs the command asynchronously with injectable text streams. -+ public static Task RunAsync( -+ string[] args, -+ TextReader? stdin = null, -+ TextWriter? stdout = null, -+ TextWriter? stderr = null, -+ CancellationToken cancellationToken = default -+ ) => RunAsync( -+ args ?? [], -+ new CommandContext( -+ ProgramName, -+ stdin ?? Console.In, -+ stdout ?? Console.Out, -+ stderr ?? Console.Error, -+ cancellationToken: cancellationToken -+ ) -+ ); -+ -+ /// Runs the command asynchronously with byte-preserving streams. -+ public static Task RunAsync( -+ string[] args, -+ Stream standardInput, -+ Stream standardOutput, -+ Stream standardError, -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentNullException.ThrowIfNull( standardInput ); -+ ArgumentNullException.ThrowIfNull( standardOutput ); -+ ArgumentNullException.ThrowIfNull( standardError ); -+ return RunCoreAsync( -+ args ?? [], -+ standardInput, -+ standardOutput, -+ standardError, -+ isInteractive: false, -+ cancellationToken -+ ); -+ } -+ -+ /// Runs the command asynchronously with a complete command context. -+ public static async Task RunAsync( -+ string[] args, -+ CommandContext context -+ ) { -+ ArgumentNullException.ThrowIfNull( context ); -+ if ( -+ null != context.StandardInputStream -+ && null != context.StandardOutputStream -+ && null != context.StandardErrorStream -+ ) { -+ return await RunCoreAsync( -+ args ?? [], -+ context.StandardInputStream, -+ context.StandardOutputStream, -+ context.StandardErrorStream, -+ isInteractive: ReferenceEquals( context.StandardInput, Console.In ) && !Console.IsInputRedirected, -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ var inputText = await context.StandardInput.ReadToEndAsync( -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ await using var input = new MemoryStream( Encoding.UTF8.GetBytes( inputText ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ var status = await RunCoreAsync( -+ args ?? [], -+ input, -+ output, -+ error, -+ isInteractive: false, -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ await context.StandardOutput.WriteAsync( -+ Encoding.UTF8.GetString( output.ToArray() ).AsMemory(), -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ await context.StandardError.WriteAsync( -+ Encoding.UTF8.GetString( error.ToArray() ).AsMemory(), -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ return status; -+ } -+ -+ /// Writes the complete command usage and option reference. -+ public static async Task WriteUsageAsync( -+ CommandContext context -+ ) { -+ ArgumentNullException.ThrowIfNull( context ); -+ const string usage = """ -+Usage: red [OPTION]... [[+LINE] FILE] -+Edit text line by line under the immutable restricted-ed capability profile. -+ -+ -E, --extended-regexp use extended regular expressions -+ -G, --traditional run in traditional compatibility mode -+ -l, --loose-exit-status exit successfully after command errors -+ -p, --prompt=STRING use STRING as the command prompt -+ -q, --quiet, --silent suppress diagnostic messages -+ -r, --restricted accepted for compatibility; red is always restricted -+ -s, --script suppress byte counts and shell completion prompts -+ -v, --verbose print diagnostic explanations -+ --strip-trailing-cr remove a trailing CR from each input record -+ --unsafe-names permit control characters in filenames -+ -h, --help display this help and exit -+ -V, --version output version information and exit -+ -+LINE may be a line number, '+', '/REGEXP/', or '?REGEXP?'. -+Commands and edited records are LF-delimited data; CRLF command input is accepted. -+"""; -+ await context.StandardOutput.WriteAsync( -+ usage.AsMemory(), -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ private static async Task RunCoreAsync( -+ string[] args, -+ Stream standardInput, -+ Stream standardOutput, -+ Stream standardError, -+ bool isInteractive, -+ CancellationToken cancellationToken -+ ) { -+ var quietDiagnostics = false; -+ try { -+ var parser = CreateParser(); -+ var parsed = parser.Parse( args ); -+ if ( !parsed.IsSuccess ) { -+ foreach ( var error in parsed.Errors ) { -+ await WriteTextLineAsync( -+ standardError, -+ OptionDiagnosticFormatter.Format( ProgramName, error ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return 1; -+ } -+ if ( parsed.HasOption( "help" ) ) { -+ await WriteUsageAsync( standardOutput, cancellationToken ).ConfigureAwait( false ); -+ return 0; -+ } -+ if ( parsed.HasOption( "version" ) ) { -+ await WriteTextLineAsync( standardOutput, Version, cancellationToken ).ConfigureAwait( false ); -+ return 0; -+ } -+ -+ var options = RedOptions.From( parsed ); -+ quietDiagnostics = options.QuietDiagnostics; -+ if ( !TryCreateInvocation( parsed.Operands, out var invocation, out var invocationError ) ) { -+ if ( !quietDiagnostics ) { -+ await WriteDiagnosticAsync( standardError, invocationError!, cancellationToken ).ConfigureAwait( false ); -+ } -+ return 1; -+ } -+ if ( -+ null != invocation.FileName -+ && !invocation.FileName.StartsWith( '!' ) -+ && !IsAllowedFileName( invocation.FileName, options.UnsafeNames ) -+ ) { -+ if ( !quietDiagnostics ) { -+ await WriteDiagnosticAsync( -+ standardError, -+ "filename contains a disallowed control character", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return 1; -+ } -+ -+ IEditorFileAccess fileAccess = new StandardEditorFileAccess(); -+ if ( options.StripTrailingCarriageReturn ) { -+ fileAccess = new CarriageReturnStrippingFileAccess( fileAccess ); -+ } -+ fileAccess = new FileNamePolicyEditorFileAccess( -+ fileAccess, -+ options.UnsafeNames -+ ); -+ var workingDirectory = Directory.GetCurrentDirectory(); -+ var profile = EditorCapabilityProfile.Restricted( workingDirectory, fileAccess ); -+ fileAccess = profile.FileAccess; -+ var expressionProvider = options.ExtendedRegularExpressions -+ ? (IRegularExpressionProvider)GnuExtendedRegularExpressionProvider.Default -+ : GnuBasicRegularExpressionProvider.Default; -+ var engine = new EditorEngine( profile, expressionProvider ); -+ -+ var initialFileError = await LoadInitialFileAsync( -+ engine, -+ fileAccess, -+ invocation, -+ options, -+ standardOutput, -+ standardError, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( null != invocation.InitialAddress ) { -+ if ( !await TryApplyInitialAddressAsync( -+ engine, -+ invocation.InitialAddress, -+ standardError, -+ options.QuietDiagnostics, -+ cancellationToken -+ ).ConfigureAwait( false ) ) { -+ return 1; -+ } -+ } -+ -+ var sessionStatus = await RunSessionAsync( -+ engine, -+ standardInput, -+ standardOutput, -+ standardError, -+ options, -+ isInteractive, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( 0 != sessionStatus ) { -+ return sessionStatus; -+ } -+ return initialFileError && !isInteractive ? 2 : 0; -+ } catch ( OperationCanceledException ) { -+ return 2; -+ } catch ( IOException exception ) { -+ if ( !quietDiagnostics ) { -+ try { -+ await WriteDiagnosticAsync( standardError, exception.Message, CancellationToken.None ).ConfigureAwait( false ); -+ } catch ( IOException ) { -+ } -+ } -+ return 1; -+ } catch ( Exception exception ) when ( -+ exception is UnauthorizedAccessException -+ or NotSupportedException -+ or ArgumentException -+ or InvalidOperationException -+ ) { -+ if ( !quietDiagnostics ) { -+ await WriteDiagnosticAsync( standardError, exception.Message, cancellationToken ).ConfigureAwait( false ); -+ } -+ return 1; -+ } catch ( Exception exception ) { -+ if ( !quietDiagnostics ) { -+ try { -+ await WriteDiagnosticAsync( -+ standardError, -+ string.Concat( "internal editor failure: ", exception.Message ), -+ CancellationToken.None -+ ).ConfigureAwait( false ); -+ } catch ( IOException ) { -+ } -+ } -+ return 3; -+ } -+ } -+ -+ private static async Task LoadInitialFileAsync( -+ EditorEngine engine, -+ IEditorFileAccess fileAccess, -+ RedInvocation invocation, -+ RedOptions options, -+ Stream standardOutput, -+ Stream standardError, -+ CancellationToken cancellationToken -+ ) { -+ if ( null == invocation.FileName ) { -+ engine.Load( [] ); -+ return false; -+ } -+ var fileName = invocation.FileName; -+ try { -+ EditorFileReadResult read; -+ if ( fileName.StartsWith( '!' ) ) { -+ throw new UnauthorizedAccessException( "Shell input is disabled in restricted mode." ); -+ } else { -+ read = await fileAccess.ReadAsync( fileName, cancellationToken ).ConfigureAwait( false ); -+ engine.Load( read.Lines, read.FinalRecordTerminated, fileName ); -+ } -+ if ( !options.ScriptMode ) { -+ await WriteTextLineAsync( -+ standardOutput, -+ read.ByteCount.ToString( CultureInfo.InvariantCulture ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return false; -+ } catch ( FileNotFoundException ) { -+ engine.Load( [], rememberedFileName: fileName ); -+ if ( !options.QuietDiagnostics ) { -+ await WriteDiagnosticAsync( -+ standardError, -+ string.Concat( fileName, ": No such file or directory" ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return true; -+ } catch ( DirectoryNotFoundException ) { -+ engine.Load( [], rememberedFileName: fileName ); -+ if ( !options.QuietDiagnostics ) { -+ await WriteDiagnosticAsync( -+ standardError, -+ string.Concat( fileName, ": No such file or directory" ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return true; -+ } -+ } -+ -+ private static async Task RunSessionAsync( -+ EditorEngine engine, -+ Stream standardInput, -+ Stream standardOutput, -+ Stream standardError, -+ RedOptions options, -+ bool isInteractive, -+ CancellationToken cancellationToken -+ ) { -+ using var reader = new ByteRecordReader( standardInput ); -+ var verbose = options.Verbose; -+ var prompt = options.Prompt; -+ var modifiedQuitWarning = false; -+ var modifiedEditWarning = false; -+ var hadError = false; -+ while ( true ) { -+ if ( null != prompt ) { -+ await standardOutput.WriteAsync( Encoding.UTF8.GetBytes( prompt ), cancellationToken ).ConfigureAwait( false ); -+ await standardOutput.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ var record = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == record ) { -+ break; -+ } -+ var commandBytes = NormalizeRecord( record.Content, record.IsTerminated ); -+ var commandText = Encoding.UTF8.GetString( commandBytes.Span ); -+ var commandCharacter = FindCommandCharacter( commandText ); -+ if ( 'H' == commandCharacter && "H" == commandText.Trim() ) { -+ verbose = !verbose; -+ continue; -+ } -+ if ( 'P' == commandCharacter && "P" == commandText.Trim() ) { -+ prompt = null == prompt ? "*" : null; -+ continue; -+ } -+ if ( 'q' == commandCharacter && modifiedQuitWarning && "q" == commandText.Trim() ) { -+ commandText = "Q"; -+ commandBytes = Encoding.UTF8.GetBytes( commandText ); -+ commandCharacter = 'Q'; -+ } -+ if ( 'e' == commandCharacter && modifiedEditWarning ) { -+ commandText = ReplaceCommandCharacter( commandText, 'E' ); -+ commandBytes = Encoding.UTF8.GetBytes( commandText ); -+ commandCharacter = 'E'; -+ } -+ -+ await using var commandStream = new MemoryStream(); -+ await commandStream.WriteAsync( commandBytes, cancellationToken ).ConfigureAwait( false ); -+ await commandStream.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ if ( commandCharacter is 'a' or 'i' or 'c' ) { -+ var terminated = false; -+ while ( true ) { -+ var data = await reader.ReadAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( null == data ) { -+ break; -+ } -+ var dataBytes = NormalizeRecord( data.Content, data.IsTerminated ); -+ await commandStream.WriteAsync( dataBytes, cancellationToken ).ConfigureAwait( false ); -+ await commandStream.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ if ( dataBytes.Span.SequenceEqual( new byte[] { (byte)'.' } ) ) { -+ terminated = true; -+ break; -+ } -+ } -+ if ( !terminated ) { -+ if ( !options.QuietDiagnostics ) { -+ await WriteQuestionAsync( standardError, cancellationToken ).ConfigureAwait( false ); -+ } -+ if ( verbose && !options.QuietDiagnostics ) { -+ await WriteTextLineAsync( -+ standardError, -+ "The command data block is not terminated by a single period.", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ return options.LooseExitStatus ? 0 : 1; -+ } -+ } -+ commandStream.Position = 0; -+ -+ var suppressInformational = options.ScriptMode && ( commandCharacter is 'e' or 'E' or 'r' or 'w' or 'W' ); -+ using var discardedOutput = suppressInformational ? new MemoryStream() : null; -+ var commandOutput = suppressInformational ? discardedOutput! : standardOutput; -+ await using var commandError = new MemoryStream(); -+ var result = await engine.ExecuteScriptAsync( -+ commandStream, -+ commandOutput, -+ commandError, -+ "", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await ForwardEngineErrorAsync( -+ commandCharacter, -+ result, -+ commandError.ToArray(), -+ standardOutput, -+ standardError, -+ options.QuietDiagnostics, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( result.IsSuccess ) { -+ modifiedQuitWarning = false; -+ modifiedEditWarning = false; -+ if ( result.QuitRequested ) { -+ return options.LooseExitStatus ? 0 : hadError ? 1 : 0; -+ } -+ continue; -+ } -+ hadError = true; -+ if ( EditorDiagnosticCode.ModifiedBuffer == result.Diagnostic?.Code ) { -+ if ( 'q' == commandCharacter ) { -+ modifiedQuitWarning = true; -+ } -+ if ( 'e' == commandCharacter ) { -+ modifiedEditWarning = true; -+ } -+ } -+ if ( verbose && !options.QuietDiagnostics && null != result.Diagnostic ) { -+ await WriteTextLineAsync( -+ standardError, -+ result.Diagnostic.Message, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ if ( EditorExitStatus.Interrupted == result.ExitStatus ) { -+ return 2; -+ } -+ if ( !isInteractive && !options.LooseExitStatus ) { -+ return EditorDiagnosticCode.ModifiedBuffer == result.Diagnostic?.Code ? 2 : 1; -+ } -+ } -+ return options.LooseExitStatus ? 0 : hadError ? 1 : 0; -+ } -+ -+ private static async ValueTask ForwardEngineErrorAsync( -+ char commandCharacter, -+ EditorExecutionResult result, -+ ReadOnlyMemory bytes, -+ Stream standardOutput, -+ Stream standardError, -+ bool quietDiagnostics, -+ CancellationToken cancellationToken -+ ) { -+ if ( bytes.IsEmpty ) { -+ return; -+ } -+ if ( result.IsSuccess && 'h' == commandCharacter ) { -+ await standardOutput.WriteAsync( bytes, cancellationToken ).ConfigureAwait( false ); -+ return; -+ } -+ var content = bytes; -+ if ( -+ quietDiagnostics -+ && !result.IsSuccess -+ && 2 <= content.Length -+ && (byte)'?' == content.Span[ ^2 ] -+ && (byte)'\n' == content.Span[ ^1 ] -+ ) { -+ content = content[ ..^2 ]; -+ } -+ if ( !content.IsEmpty ) { -+ await standardError.WriteAsync( content, cancellationToken ).ConfigureAwait( false ); -+ } -+ } -+ -+ private static async Task TryApplyInitialAddressAsync( -+ EditorEngine engine, -+ string initialAddress, -+ Stream standardError, -+ bool quietDiagnostics, -+ CancellationToken cancellationToken -+ ) { -+ if ( "+" == initialAddress ) { -+ engine.SetCurrentAddress( engine.Buffer.Count ); -+ return true; -+ } -+ var text = initialAddress[ 1.. ]; -+ if ( int.TryParse( text, NumberStyles.None, CultureInfo.InvariantCulture, out var address ) ) { -+ try { -+ engine.SetCurrentAddress( Math.Min( address, engine.Buffer.Count ) ); -+ return true; -+ } catch ( ArgumentOutOfRangeException ) { -+ if ( !quietDiagnostics ) { -+ await WriteQuestionAsync( standardError, cancellationToken ).ConfigureAwait( false ); -+ } -+ return false; -+ } -+ } -+ if ( text.StartsWith( '/' ) && !text.EndsWith( '/' ) ) { -+ text = string.Concat( text, "/" ); -+ } else if ( text.StartsWith( '?' ) && !text.EndsWith( '?' ) ) { -+ text = string.Concat( text, "?" ); -+ } -+ await using var script = new MemoryStream( Encoding.UTF8.GetBytes( string.Concat( text, "=\n" ) ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ var result = await engine.ExecuteScriptAsync( -+ script, -+ output, -+ error, -+ "", -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( !result.IsSuccess ) { -+ if ( !quietDiagnostics ) { -+ await standardError.WriteAsync( error.ToArray(), cancellationToken ).ConfigureAwait( false ); -+ } -+ return false; -+ } -+ var value = Encoding.UTF8.GetString( output.ToArray() ).Trim(); -+ if ( !int.TryParse( value, NumberStyles.None, CultureInfo.InvariantCulture, out address ) ) { -+ if ( !quietDiagnostics ) { -+ await WriteQuestionAsync( standardError, cancellationToken ).ConfigureAwait( false ); -+ } -+ return false; -+ } -+ engine.SetCurrentAddress( address ); -+ return true; -+ } -+ -+ private static bool TryCreateInvocation( -+ IReadOnlyList operands, -+ out RedInvocation invocation, -+ out string? error -+ ) { -+ string? initialAddress = null; -+ string? fileName = null; -+ var index = 0; -+ if ( 0 < operands.Count && operands[ 0 ].StartsWith( '+') ) { -+ initialAddress = operands[ 0 ]; -+ index++; -+ } -+ if ( index < operands.Count ) { -+ fileName = operands[ index++ ]; -+ } -+ if ( index != operands.Count ) { -+ invocation = default!; -+ error = "too many file operands"; -+ return false; -+ } -+ invocation = new RedInvocation( initialAddress, fileName ); -+ error = null; -+ return true; -+ } -+ -+ private static OptionParser CreateParser() => new( -+ [ -+ new OptionDefinition( "extended", 'E', [ "extended-regexp" ], allowMultiple: false ), -+ new OptionDefinition( "traditional", 'G', [ "traditional" ], allowMultiple: false ), -+ new OptionDefinition( "loose", 'l', [ "loose-exit-status" ], allowMultiple: false ), -+ new OptionDefinition( "prompt", 'p', [ "prompt" ], OptionValueArity.Required, allowMultiple: false ), -+ new OptionDefinition( "quiet", 'q', [ "quiet", "silent" ], allowMultiple: false ), -+ new OptionDefinition( "restricted", 'r', [ "restricted" ], allowMultiple: false ), -+ new OptionDefinition( "script", 's', [ "script" ], allowMultiple: false ), -+ new OptionDefinition( "verbose", 'v', [ "verbose" ], allowMultiple: false ), -+ new OptionDefinition( "strip-cr", null, [ "strip-trailing-cr" ], allowMultiple: false ), -+ new OptionDefinition( "unsafe-names", null, [ "unsafe-names" ], allowMultiple: false ), -+ new OptionDefinition( "help", 'h', [ "help" ], allowMultiple: false ), -+ new OptionDefinition( "version", 'V', [ "version" ], allowMultiple: false ), -+ ], -+ new OptionParserSettings { -+ AllowLongOptionAbbreviations = true, -+ Ordering = OptionOrdering.Permute, -+ } -+ ); -+ -+ private static char FindCommandCharacter( -+ string text -+ ) { -+ var index = FindCommandIndex( text ); -+ return 0 > index ? '\0' : text[ index ]; -+ } -+ -+ private static int FindCommandIndex( -+ string text -+ ) { -+ var escaped = false; -+ var delimiter = '\0'; -+ var afterMark = false; -+ for ( var index = 0; text.Length > index; index++ ) { -+ var character = text[ index ]; -+ if ( '\0' != delimiter ) { -+ if ( escaped ) { -+ escaped = false; -+ continue; -+ } -+ if ( '\\' == character ) { -+ escaped = true; -+ continue; -+ } -+ if ( delimiter == character ) { -+ delimiter = '\0'; -+ } -+ continue; -+ } -+ if ( afterMark ) { -+ afterMark = false; -+ continue; -+ } -+ if ( '\'' == character ) { -+ afterMark = true; -+ continue; -+ } -+ if ( character is '/' or '?' ) { -+ delimiter = character; -+ continue; -+ } -+ if ( char.IsLetter( character ) || character is '!' or '=' or '#' ) { -+ return index; -+ } -+ } -+ return -1; -+ } -+ -+ private static string ReplaceCommandCharacter( -+ string text, -+ char replacement -+ ) { -+ var index = FindCommandIndex( text ); -+ if ( 0 > index ) { -+ return text; -+ } -+ return string.Concat( text.Substring( 0, index ), replacement.ToString(), text.Substring( index + 1 ) ); -+ } -+ -+ private static ReadOnlyMemory NormalizeRecord( -+ ReadOnlyMemory content, -+ bool terminated -+ ) { -+ if ( terminated && !content.IsEmpty && (byte)'\r' == content.Span[ ^1 ] ) { -+ return content[ ..^1 ].ToArray(); -+ } -+ return content.ToArray(); -+ } -+ -+ private static bool IsAllowedFileName( -+ string fileName, -+ bool allowUnsafeNames -+ ) { -+ if ( fileName.Any( character => character is '\0' or '\n' ) ) { -+ return false; -+ } -+ if ( allowUnsafeNames ) { -+ return true; -+ } -+ return fileName.All( -+ character => character is not ( '\a' or '\b' or '\t' or '\v' or '\f' or '\r' or '\u001B' or '\u007F' ) -+ ); -+ } -+ -+ private static async ValueTask WriteUsageAsync( -+ Stream output, -+ CancellationToken cancellationToken -+ ) { -+ const string usage = """ -+Usage: red [OPTION]... [[+LINE] FILE] -+Try 'red --help' for more information. -+"""; -+ await output.WriteAsync( Encoding.UTF8.GetBytes( usage ), cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private static ValueTask WriteDiagnosticAsync( -+ Stream error, -+ string message, -+ CancellationToken cancellationToken -+ ) => WriteTextLineAsync( -+ error, -+ string.Concat( ProgramName, ": ", message ), -+ cancellationToken -+ ); -+ -+ private static async ValueTask WriteQuestionAsync( -+ Stream error, -+ CancellationToken cancellationToken -+ ) { -+ await error.WriteAsync( new byte[] { (byte)'?', (byte)'\n' }, cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private static async ValueTask WriteTextLineAsync( -+ Stream output, -+ string text, -+ CancellationToken cancellationToken -+ ) { -+ await output.WriteAsync( Encoding.UTF8.GetBytes( text ), cancellationToken ).ConfigureAwait( false ); -+ await output.WriteAsync( LineFeed, cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private sealed record RedInvocation( -+ string? InitialAddress, -+ string? FileName -+ ); -+ -+ private sealed record RedOptions( -+ bool ExtendedRegularExpressions, -+ bool Traditional, -+ bool LooseExitStatus, -+ string? Prompt, -+ bool QuietDiagnostics, -+ bool ScriptMode, -+ bool Verbose, -+ bool StripTrailingCarriageReturn, -+ bool UnsafeNames -+ ) { -+ public static RedOptions From( -+ OptionParseResult result -+ ) => new( -+ result.HasOption( "extended" ), -+ result.HasOption( "traditional" ), -+ result.HasOption( "loose" ), -+ result.GetLastValue( "prompt" ), -+ result.HasOption( "quiet" ), -+ result.HasOption( "script" ), -+ result.HasOption( "verbose" ), -+ result.HasOption( "strip-cr" ), -+ result.HasOption( "unsafe-names" ) -+ ); -+ } -+ -+ private sealed class FileNamePolicyEditorFileAccess : IEditorFileAccess { -+ private readonly IEditorFileAccess inner; -+ private readonly bool allowUnsafeNames; -+ -+ public FileNamePolicyEditorFileAccess( -+ IEditorFileAccess inner, -+ bool allowUnsafeNames -+ ) { -+ ArgumentNullException.ThrowIfNull( inner ); -+ this.inner = inner; -+ this.allowUnsafeNames = allowUnsafeNames; -+ } -+ -+ public ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ this.Validate( path ); -+ return this.inner.ReadAsync( path, cancellationToken ); -+ } -+ -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) { -+ this.Validate( path ); -+ return this.inner.WriteAsync( -+ path, -+ lines, -+ append, -+ terminateFinalRecord, -+ cancellationToken -+ ); -+ } -+ -+ private void Validate( -+ string path -+ ) { -+ if ( !IsAllowedFileName( path, this.allowUnsafeNames ) ) { -+ throw new UnauthorizedAccessException( "The filename contains a disallowed control character." ); -+ } -+ } -+ } -+ -+ private sealed class CarriageReturnStrippingFileAccess : IEditorFileAccess { -+ private readonly IEditorFileAccess inner; -+ -+ public CarriageReturnStrippingFileAccess( -+ IEditorFileAccess inner -+ ) { -+ ArgumentNullException.ThrowIfNull( inner ); -+ this.inner = inner; -+ } -+ -+ public async ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ var result = await this.inner.ReadAsync( path, cancellationToken ).ConfigureAwait( false ); -+ return result with { -+ Lines = result.Lines.Select( -+ ( line, index ) => -+ !line.IsEmpty -+ && (byte)'\r' == line.Span[ ^1 ] -+ && ( result.FinalRecordTerminated || result.Lines.Count - 1 != index ) -+ ? new ReadOnlyMemory( line[ ..^1 ].ToArray() ) -+ : new ReadOnlyMemory( line.ToArray() ) -+ ).ToArray() -+ }; -+ } -+ -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) => this.inner.WriteAsync( -+ path, -+ lines, -+ append, -+ terminateFinalRecord, -+ cancellationToken -+ ); -+ } -+} -diff --git a/sed/Icod.LineEditor.Sed.csproj b/sed/Icod.LineEditor.Sed.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..ae0d2dcd3d4792f055bb49a8b0c418a9e6ce14d6 ---- /dev/null -+++ b/sed/Icod.LineEditor.Sed.csproj -@@ -0,0 +1,52 @@ -+ -+ -+ -+ Exe -+ net10.0 -+ 13.0 -+ enable -+ enable -+ true -+ ..\bin\$(Configuration)\ -+ sed -+ Icod.LineEditor.Sed -+ -+ -+ AnyCPU -+ -+ -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ false -+ pdbonly -+ true -+ prompt -+ 4 -+ true -+ CS1591 -+ -+ -\ No newline at end of file -diff --git a/sed/Program.cs b/sed/Program.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..86c68cbbfa60fe028fdbdc2dc3abfe5f32404f00 ---- /dev/null -+++ b/sed/Program.cs -@@ -0,0 +1,37 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.Diagnostics; -+ -+/// -+/// Provides the executable entry point for the GNU-compatible sed command for transforming text with stream-editor commands. -+/// -+public static class Program { -+ /// -+ /// Runs the sed command using the process console and converts a console interrupt into a cancellation request. -+ /// -+ /// The command-line arguments supplied to sed. -+ /// A task whose result is the command exit status. -+ public static async Task Main( -+ string[] args -+ ) { -+ using var cancellation = new CancellationTokenSource(); -+ Console.CancelKeyPress += ( -+ sender, -+ eventArgs -+ ) => { -+ eventArgs.Cancel = true; -+ cancellation.Cancel(); -+ }; -+ var context = CommandContext.CreateConsole( -+ "sed", -+ cancellation.Token -+ ); -+ return await Command.RunAsync( -+ args, -+ context -+ ).ConfigureAwait( false ); -+ } -+ -+} -diff --git a/sed/src/Command.cs b/sed/src/Command.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..946aa324e2319b70be696ace407c2a112854a457 ---- /dev/null -+++ b/sed/src/Command.cs -@@ -0,0 +1,394 @@ -+// Original behavior/reference: sed (Lee E. McMahon) -+// Ported to .NET by Timothy J. Bruce -+ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+ -+/// -+/// Implements a portable GNU-compatible sed stream editor using Shared -+/// managed GNU regular expressions and byte-preserving record processing. -+/// -+/// -+/// -+/// The command processor implements addressed commands, inclusive address -+/// ranges, negation, command groups, labels and branches, pattern space, -+/// hold space, substitution, transliteration, explicit printing, file -+/// reads and writes, next-cycle commands, and in-place editing. Primary -+/// input, script files, auxiliary files, and output are processed with TAP -+/// operations. Input uses one-record lookahead and is never fully materialized. -+/// -+/// -+/// In syntax descriptions, M and N are metavariables for -+/// non-negative or positive decimal line numbers as required by the command. -+/// They are not literal characters in a sed program. -+/// -+/// -+/// Supported command-line options include -n, -e, -f, -+/// -i[SUFFIX], -E/-r, -s, -u, -+/// -z, -l N, --sandbox, --help, and -+/// --version. -+/// -+/// -+/// Supported addresses include line numbers, $, regular-expression -+/// addresses, GNU-style first~step addresses, and range ends -+/// +N and ~N. An address or range may be followed by -+/// ! to negate its selection. -+/// -+/// -+/// Supported commands are = a b c d D e g G h H i l n N p P q Q r R -+/// s t T w W x y, labels introduced with :, comments introduced -+/// with #, and grouped commands enclosed in braces. -+/// -+/// -+/// Regular expressions are compiled through the Shared managed GNU BRE/ERE -+/// provider. Sed retains command-local policy for empty-expression reuse, -+/// address and substitution modifiers, occurrence selection, zero-length -+/// match iteration, replacement expansion, and diagnostic presentation. -+/// -+/// -+public static partial class Command { -+ -+ #region fields -+ private const int DefaultListWidth = 70; -+ private const int ErrorExitCode = CommandExitCodes.Failure; -+ private const int UsageExitCode = CommandExitCodes.UsageError; -+ private const string VersionText = "Icod.LineEditor.Sed 1.0"; -+ #endregion fields -+ -+ /// -+ /// Executes sed synchronously with optional standard-stream substitution. -+ /// -+ /// -+ /// This compatibility entry point blocks on the TAP implementation. A text stream selects the corresponding stream; caller-supplied streams remain caller-owned. -+ /// -+ /// The command-line arguments, excluding the executable name. -+ /// The text reader to use as standard input, or to use . -+ /// The text writer to use as standard output, or to use . -+ /// The text writer to use as standard error, or to use . -+ /// The GNU-compatible process exit status: zero for successful command execution and nonzero for a usage or operational failure. -+ public static int Run( -+ string[] args, -+ TextReader? stdin = null, -+ TextWriter? stdout = null, -+ TextWriter? stderr = null -+ ) { -+ return RunAsync( -+ args, -+ stdin, -+ stdout, -+ stderr, -+ CancellationToken.None -+ ).GetAwaiter().GetResult(); -+ } -+ -+ /// -+ /// Executes sed asynchronously with optional injected standard streams. -+ /// -+ /// -+ /// A text stream selects the corresponding stream. Caller-supplied streams remain caller-owned. -+ /// -+ /// The command-line arguments, excluding the executable name. -+ /// The text reader to use as standard input, or to use . -+ /// The text writer to use as standard output, or to use . -+ /// The text writer to use as standard error, or to use . -+ /// The token used to cancel parsing, platform queries, and asynchronous I/O. -+ /// The GNU-compatible process exit status: zero for successful command execution and nonzero for a usage or operational failure. -+ public static Task RunAsync( -+ string[] args, -+ TextReader? stdin = null, -+ TextWriter? stdout = null, -+ TextWriter? stderr = null, -+ CancellationToken cancellationToken = default -+ ) { -+ return RunAsync( -+ args, -+ new CommandContext( -+ "sed", -+ stdin ?? Console.In, -+ stdout ?? Console.Out, -+ stderr ?? Console.Error, -+ cancellationToken: cancellationToken -+ ) -+ ); -+ } -+ -+ /// Executes Sed through the repository-standard command context. -+ /// The command-line arguments, excluding the executable name. -+ /// The caller-owned standard streams, diagnostics, identity, and cancellation context. -+ /// The GNU-compatible process exit status. -+ public static Task RunAsync( -+ string[] args, -+ CommandContext context -+ ) { -+ return RunAsync( args, context, SedRuntimeCapabilities.System ); -+ } -+ -+ /// Executes Sed through an injectable capability profile. -+ internal static async Task RunAsync( -+ string[] args, -+ CommandContext context, -+ SedRuntimeCapabilities capabilities -+ ) { -+ ArgumentNullException.ThrowIfNull( context ); -+ ArgumentNullException.ThrowIfNull( capabilities ); -+ -+ using var inputAdapter = null == context.StandardInputStream -+ ? new TextReaderInputStream( context.StandardInput ) -+ : null -+ ; -+ using var outputAdapter = null == context.StandardOutputStream -+ ? new TextWriterOutputStream( context.StandardOutput ) -+ : null -+ ; -+ using var presentationAdapter = null != context.StandardOutputStream -+ ? new StreamWriter( -+ context.StandardOutputStream, -+ new UTF8Encoding( encoderShouldEmitUTF8Identifier: false ), -+ 8192, -+ leaveOpen: true -+ ) { -+ NewLine = "\n" -+ } -+ : null -+ ; -+ -+ try { -+ return await RunCoreAsync( -+ args, -+ context.StandardInputStream ?? inputAdapter!, -+ context.StandardOutputStream ?? outputAdapter!, -+ presentationAdapter ?? context.StandardOutput, -+ context.StandardError, -+ capabilities, -+ context.CancellationToken -+ ).ConfigureAwait( false ); -+ } finally { -+ if ( null != presentationAdapter ) { -+ await presentationAdapter.FlushAsync().ConfigureAwait( false ); -+ } -+ } -+ } -+ -+ /// Executes Sed against caller-owned byte streams. -+ /// The command-line arguments, excluding the executable name. -+ /// The caller-owned standard-input byte stream. -+ /// The caller-owned standard-output byte stream. -+ /// The caller-owned standard-error text writer. -+ /// The token used to cancel parsing and asynchronous I/O. -+ /// The GNU-compatible process exit status. -+ internal static async Task RunStreamAsync( -+ string[] args, -+ Stream stdin, -+ Stream stdout, -+ TextWriter stderr, -+ CancellationToken cancellationToken = default -+ ) { -+ ArgumentNullException.ThrowIfNull( stdin ); -+ ArgumentNullException.ThrowIfNull( stdout ); -+ ArgumentNullException.ThrowIfNull( stderr ); -+ using var presentationOutput = new StreamWriter( -+ stdout, -+ new UTF8Encoding( encoderShouldEmitUTF8Identifier: false ), -+ 8192, -+ leaveOpen: true -+ ) { -+ NewLine = "\n" -+ }; -+ try { -+ return await RunCoreAsync( -+ args, -+ stdin, -+ stdout, -+ presentationOutput, -+ stderr, -+ SedRuntimeCapabilities.System, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } finally { -+ await presentationOutput.FlushAsync().ConfigureAwait( false ); -+ } -+ } -+ -+ private static async Task RunCoreAsync( -+ string[] args, -+ Stream stdin, -+ Stream stdout, -+ TextWriter presentationOutput, -+ TextWriter stderr, -+ SedRuntimeCapabilities capabilities, -+ CancellationToken cancellationToken -+ ) { -+ args ??= Array.Empty(); -+ try { -+ var options = new Options(); -+ var scriptSources = new List(); -+ var files = new List(); -+ var argumentResult = await ParseArgumentsAsync( -+ args, -+ options, -+ scriptSources, -+ files, -+ presentationOutput, -+ stderr, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ await presentationOutput.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ if ( argumentResult.HasValue ) { -+ return argumentResult.Value; -+ } -+ -+ if ( 0 == scriptSources.Count ) { -+ if ( 0 == files.Count ) { -+ await stderr.WriteLineAsync( "sed: no script was provided" ).ConfigureAwait( false ); -+ return UsageExitCode; -+ } -+ scriptSources.Add( -+ new SedScriptSource( -+ SedScriptSourceKind.ImplicitOperand, -+ "command-line script", -+ files[ 0 ], -+ 0 -+ ) -+ ); -+ files.RemoveAt( 0 ); -+ } -+ if ( 0 == files.Count ) { -+ files.Add( "-" ); -+ } -+ if ( options.InPlace ) { -+ options.Separate = true; -+ } -+ if ( options.InPlace && files.Any( path => "-" == path ) ) { -+ await stderr.WriteLineAsync( "sed: cannot edit standard input in-place" ).ConfigureAwait( false ); -+ return UsageExitCode; -+ } -+ -+ var textCodec = SedTextCodec.CreateCurrent(); -+ var scriptDocument = SedScriptDocument.Create( scriptSources ); -+ var scriptText = scriptDocument.Text; -+ var program = new ScriptParser( -+ scriptDocument, -+ options.ExtendedRegularExpressions, -+ options.Sandbox, -+ options.Posix, -+ options.NullData, -+ textCodec.Locale, -+ cancellationToken -+ ).Parse(); -+ var runtimeCapabilities = options.Sandbox -+ ? capabilities.ForSandbox() -+ : capabilities -+ ; -+ -+ if ( options.Debug ) { -+ await stderr.WriteLineAsync( "SED PROGRAM:" ).ConfigureAwait( false ); -+ foreach ( var scriptLine in scriptText.Split( '\n' ) ) { -+ await stderr.WriteLineAsync( $" {scriptLine.TrimEnd( '\r' )}" ).ConfigureAwait( false ); -+ } -+ } -+ -+ if ( options.InPlace ) { -+ foreach ( var path in files ) { -+ var result = await ProcessInPlaceAsync( -+ path, -+ options, -+ program, -+ textCodec, -+ stderr, -+ runtimeCapabilities, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( result.Quit ) { -+ return result.ExitCode; -+ } -+ } -+ return 0; -+ } -+ -+ if ( options.Separate ) { -+ var separateOutput = new SedOutputWriter( stdout, textCodec, options.NullData ) { -+ AutoFlush = options.Unbuffered -+ }; -+ foreach ( var path in files ) { -+ using var input = new InputSequence( -+ new SourceSpec[] { new SourceSpec( path ) }, -+ stdin, -+ options.NullData, -+ textCodec -+ ); -+ var environment = new ExecutionEnvironment( -+ separateOutput, -+ textCodec, -+ stderr, -+ options.SuppressAutomaticPrint, -+ options.NullData, -+ options.ListWidth, -+ options.Debug, -+ runtimeCapabilities.Shell, -+ runtimeCapabilities.AuxiliaryFiles -+ ); -+ try { -+ var result = await ExecuteAsync( program, input, environment, cancellationToken ).ConfigureAwait( false ); -+ if ( result.Quit ) { -+ return result.ExitCode; -+ } -+ } finally { -+ await environment.DisposeAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ } -+ return 0; -+ } -+ -+ var sharedEnvironment = new ExecutionEnvironment( -+ stdout, -+ textCodec, -+ stderr, -+ options.SuppressAutomaticPrint, -+ options.NullData, -+ options.ListWidth, -+ options.Debug, -+ options.Unbuffered, -+ runtimeCapabilities.Shell, -+ runtimeCapabilities.AuxiliaryFiles -+ ); -+ try { -+ using var input = new InputSequence( -+ files.Select( path => new SourceSpec( path ) ).ToArray(), -+ stdin, -+ options.NullData, -+ textCodec -+ ); -+ return ( -+ await ExecuteAsync( program, input, sharedEnvironment, cancellationToken ).ConfigureAwait( false ) -+ ).ExitCode; -+ } finally { -+ await sharedEnvironment.DisposeAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ } catch ( ScriptParseException ex ) { -+ await stderr.WriteLineAsync( $"sed: {ex.Message}" ).ConfigureAwait( false ); -+ return UsageExitCode; -+ } catch ( OperationCanceledException ) { -+ await stderr.WriteLineAsync( "sed: operation canceled" ).ConfigureAwait( false ); -+ return CommandExitCodes.Canceled; -+ } catch ( SedCapabilityDeniedException ex ) { -+ await stderr.WriteLineAsync( $"sed: {ex.Message}" ).ConfigureAwait( false ); -+ return ErrorExitCode; -+ } catch ( Exception ex ) { -+ await stderr.WriteLineAsync( $"sed: {ex.Message}" ).ConfigureAwait( false ); -+ return ErrorExitCode; -+ } -+ } -+ -+} -diff --git a/sed/src/README.md b/sed/src/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..26c1a1aec5d389d0e438fc46a1d46992b7e8eb9d ---- /dev/null -+++ b/sed/src/README.md -@@ -0,0 +1,64 @@ -+# Icod.LineEditor.Sed source layout -+ -+Phase LE1 decomposed the original single-file Sed implementation without changing its public API. Phase LE3 migrated regular-expression execution to the Shared managed GNU BRE/ERE provider, Phase LE4 supplied byte-preserving record framing and locale-selected text semantics, and Phase LE5 now routes orchestration and host effects through `CommandContext` and injectable capabilities while preserving Sed-specific state and policy inside the command. `Icod.LineEditor.Sed.Command` remains one public partial class so every previously private implementation type remains private to the command boundary while source ownership becomes reviewable. -+ -+## Modules -+ -+| File | Responsibility | -+|---|---| -+| `Command.cs` | Public `CommandContext` core, text-stream compatibility overloads, internal byte-stream facade, stable exit-status boundary, and command constants. | -+| `SedOptions.cs` | Command-line options, shared option-parser integration, help text, and version handling. | -+| `SedScriptSources.cs` | Ordered `-e`, `-f`, and implicit script-source identity, LF-only composition, and source line/column mapping. | -+| `SedScripting.cs` | Instruction kinds, program model, source-aware script parser, and text/file arguments. | -+| `SedAddresses.cs` | Single addresses, GNU range extensions, range state, negation, and selection evaluation. | -+| `SedExecution.cs` | Pattern/hold-space command cycle, explicit termination propagation, deferred output, execution state, debug presentation, and list formatting. | -+| `SedRecords.cs` | `SedInputRecord`, Shared LF/NUL byte framing, source and record identity, C/POSIX and UTF-8 codecs, invalid-byte preservation, one-record lookahead, explicit output serialization, and text-stream compatibility adapters. | -+| `SedRegularExpressions.cs` | `SedRegularExpressionCompiler`, GNU Sed escape preprocessing, Shared BRE/ERE provider selection, empty-expression reuse, GNU/POSIX policy, locale selection, controlled diagnostics, and GNU zero-length match iteration. | -+| `SedSubstitution.cs` | Substitution flags, replacement expansion, transliteration, and character-set expansion. | -+| `SedCapabilities.cs` | Injectable shell, auxiliary-file, and in-place-edit contracts plus system and denied sandbox profiles. | -+| `SedProcesses.cs` | System shell capability through Shared `ProcessRunner` and text-writer stream adaptation. | -+| `SedFiles.cs` | `IInPlaceEditor` orchestration and the provisional command-local replacement implementation; LE10 later migrates publication to E6. | -+ -+## LE1 invariants -+ -+- `Command.Run` and `Command.RunAsync` retain their signatures and caller-owned stream behavior. -+- All implementation types remain non-public details of `Command`. -+- Script expressions, files, and the implicit operand now retain stable source identity and are composed with LF rather than `Environment.NewLine`. -+- Record reading and writing follow the LE4 byte-preserving record and explicit final-termination contract. -+- Regular expressions compile through the Shared managed GNU provider; Sed continues to own empty-expression reuse, address/substitution modifiers, occurrence selection, zero-length iteration, replacement expansion, and diagnostics. -+- In-place editing retains the existing command-local replacement mechanics behind `IInPlaceEditor`; LE10 performs the E6 migration. -+ -+The characterization tests in `tests/Sed.Tests/src/SedCharacterizationTests.cs` record these temporary semantics so later phases can distinguish intentional semantic work from accidental refactoring regressions. -+ -+## LE3 regular-expression boundary -+ -+- `SedRegularExpressionCompiler` selects Shared Basic or Extended syntax once per script parser. -+- A nonempty address or substitution expression becomes the new shared "last regular expression"; an empty expression reuses the exact compiled object, including its original `I`/`M` policy. -+- Address `I` and `M` modifiers and substitution `i`/`I` and `m`/`M` flags remain Sed syntax. New modifiers on an empty expression are rejected. -+- GNU/POSIX mode is interpreted by the adapter rather than by weakening the Shared provider contract. GNU Sed control and numeric escapes are expanded before regex parsing; `--posix` suppresses that expansion only inside raw bracket expressions. -+- Global substitution iteration follows GNU Sed's empty-match progression rule and consumes Shared leftmost-longest matches. -+- Locale classification uses the LE4 Shared text-locale profile: C/POSIX selects byte classification, while every UTF-8 profile uses Unicode character classes with the process culture for collation, including invariant culture. -+ -+## LE4 record and text boundary -+ -+- Standard input and files are framed with Shared `ByteRecordReader`; only the configured LF or NUL separator is removed. -+- `SedInputRecord` retains authoritative bytes, decoded text, source identity, aggregate and per-source numbers, separator kind, final termination, and representable byte coordinates. -+- `TextLocaleEnvironment` selects a C/POSIX byte profile or UTF-8 profile. Malformed UTF-8 bytes map to reversible reserved UTF-16 code units rather than replacement characters. -+- Data output uses Shared `DelimitedByteRecordWriter`; host newlines are presentation-only. -+- The active record separator is also the internal pattern/hold-space separator used by `N`, `D`, `P`, `H`, `G`, and `W`. `l` renders internal NUL as `\000`. -+- `SedRegularExpressionCompiler` passes LF or NUL to Shared `RegularExpressionOptions.LineSeparator`; `-z` explicitly enables NUL dot matching outside multiline mode, while `M` treats NUL as the line boundary. -+- The executable uses raw standard streams. The public text-stream methods remain compatibility adapters and do not own caller streams. -+- The current record and one lookahead record are retained. Pattern and hold spaces may grow without a fixed bound because that growth is required by Sed commands. -+ -+See `Icod.LineEditor-LE4-Record-and-Text-Semantics.md` for the complete contract and deferred LE5 boundary. -+ -+## LE5 orchestration and capability boundary -+ -+- `RunAsync(string[] args, CommandContext context)` is the primary command path and uses binary context streams when present. -+- `SedScriptSource` and `SedScriptDocument` preserve source identity, order, and source-relative diagnostics without host-newline joining. -+- Shell execution, auxiliary file access, and in-place editing are injectable command capabilities. -+- `SystemSedShellCapability` continues to use Shared `ProcessRunner`. -+- Sandbox compilation rejects prohibited commands, and denied runtime capabilities provide a second enforcement layer. -+- `SystemInPlaceEditor` uses Shared secure temporary objects and cleans failed stages; LE10 remains responsible for final E6 publication. -+ -+See `Icod.LineEditor-LE5-Orchestration-and-Capabilities.md` for the complete contract and acceptance coverage. -diff --git a/sed/src/SedAddresses.cs b/sed/src/SedAddresses.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..8ff7d70804734f31603d1737b08a2903639e00fc ---- /dev/null -+++ b/sed/src/SedAddresses.cs -@@ -0,0 +1,426 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+ -+// Responsibility: address, range, and selection. -+public static partial class Command { -+ -+ private readonly struct AddressContext { -+ -+ public long LineNumber { -+ get; -+ } -+ -+ public bool IsLastLine { -+ get; -+ } -+ -+ public string PatternSpace { -+ get; -+ } -+ -+ public CancellationToken CancellationToken { -+ get; -+ } -+ -+ public AddressContext( -+ long lineNumber, -+ bool isLastLine, -+ string patternSpace, -+ CancellationToken cancellationToken -+ ) { -+ this.LineNumber = lineNumber; -+ this.IsLastLine = isLastLine; -+ this.PatternSpace = patternSpace; -+ this.CancellationToken = cancellationToken; -+ } -+ -+ } -+ -+ private abstract class Address { -+ -+ public virtual bool IsRegularExpression { -+ get { -+ return false; -+ } -+ } -+ -+ public abstract bool Matches( -+ in AddressContext context -+ ); -+ -+ public virtual bool MatchesRangeEnd( -+ in AddressContext context -+ ) { -+ return this.Matches( -+ context -+ ); -+ } -+ -+ } -+ -+ private sealed class ZeroAddress : Address { -+ -+ public override bool Matches( -+ in AddressContext context -+ ) { -+ return false; -+ } -+ -+ } -+ -+ private sealed class LineAddress : Address { -+ -+ public int LineNumber { -+ get; -+ } -+ -+ public LineAddress( -+ int lineNumber -+ ) { -+ if ( lineNumber <= 0 ) { -+ throw new ArgumentOutOfRangeException( -+ nameof( lineNumber ) -+ ); -+ } -+ this.LineNumber = lineNumber; -+ } -+ -+ public override bool Matches( -+ in AddressContext context -+ ) { -+ return context.LineNumber == this.LineNumber; -+ } -+ -+ public override bool MatchesRangeEnd( -+ in AddressContext context -+ ) { -+ return context.LineNumber >= this.LineNumber; -+ } -+ -+ } -+ -+ private sealed class StepAddress : Address { -+ -+ public int First { -+ get; -+ } -+ -+ public int Step { -+ get; -+ } -+ -+ public StepAddress( -+ int first, -+ int step -+ ) { -+ if ( first < 0 ) { -+ throw new ArgumentOutOfRangeException( -+ nameof( first ) -+ ); -+ } else if ( step <= 0 ) { -+ throw new ArgumentOutOfRangeException( -+ nameof( step ) -+ ); -+ } -+ -+ this.First = first; -+ this.Step = step; -+ } -+ -+ public override bool Matches( -+ in AddressContext context -+ ) { -+ var first = 0 == this.First -+ ? this.Step -+ : this.First -+ ; -+ return ( -+ first <= context.LineNumber -+ && 0 == ( context.LineNumber - first ) % this.Step -+ ); -+ } -+ -+ } -+ -+ private sealed class LastLineAddress : Address { -+ -+ public override bool Matches( -+ in AddressContext context -+ ) { -+ return context.IsLastLine; -+ } -+ -+ } -+ -+ private sealed class RegexAddress : Address { -+ -+ private readonly SedCompiledRegularExpression myRegularExpression; -+ -+ public override bool IsRegularExpression { -+ get { -+ return true; -+ } -+ } -+ -+ public RegexAddress( -+ SedCompiledRegularExpression regularExpression -+ ) { -+ this.myRegularExpression = regularExpression -+ ?? throw new ArgumentNullException( -+ nameof( regularExpression ) -+ ) -+ ; -+ } -+ -+ public override bool Matches( -+ in AddressContext context -+ ) { -+ return this.myRegularExpression.IsMatch( -+ context.PatternSpace, -+ context.CancellationToken -+ ); -+ } -+ -+ } -+ -+ private abstract class RangeEnd { -+ -+ public abstract bool IsEnd( -+ in AddressContext context, -+ long rangeStartLine, -+ bool isStartLine -+ ); -+ -+ } -+ -+ private sealed class AddressRangeEnd : RangeEnd { -+ -+ private readonly Address myAddress; -+ -+ public AddressRangeEnd( -+ Address address -+ ) { -+ this.myAddress = address ?? throw new ArgumentNullException( -+ nameof( address ) -+ ); -+ } -+ -+ public override bool IsEnd( -+ in AddressContext context, -+ long rangeStartLine, -+ bool isStartLine -+ ) { -+ if ( -+ isStartLine -+ && this.myAddress.IsRegularExpression -+ ) { -+ return false; -+ } -+ -+ return this.myAddress.MatchesRangeEnd( -+ context -+ ); -+ } -+ -+ } -+ -+ private sealed class RelativeRangeEnd : RangeEnd { -+ -+ private readonly int myAdditionalLines; -+ -+ public RelativeRangeEnd( -+ int additionalLines -+ ) { -+ if ( additionalLines < 0 ) { -+ throw new ArgumentOutOfRangeException( -+ nameof( additionalLines ) -+ ); -+ } -+ this.myAdditionalLines = additionalLines; -+ } -+ -+ public override bool IsEnd( -+ in AddressContext context, -+ long rangeStartLine, -+ bool isStartLine -+ ) { -+ return context.LineNumber >= rangeStartLine + this.myAdditionalLines; -+ } -+ -+ } -+ -+ private sealed class MultipleRangeEnd : RangeEnd { -+ -+ private readonly int myMultiple; -+ -+ public MultipleRangeEnd( -+ int multiple -+ ) { -+ if ( multiple <= 0 ) { -+ throw new ArgumentOutOfRangeException( -+ nameof( multiple ) -+ ); -+ } -+ this.myMultiple = multiple; -+ } -+ -+ public override bool IsEnd( -+ in AddressContext context, -+ long rangeStartLine, -+ bool isStartLine -+ ) { -+ return ( -+ !isStartLine -+ && 0 == context.LineNumber % this.myMultiple -+ ); -+ } -+ -+ } -+ -+ private readonly struct Selection { -+ -+ public bool IsSelected { -+ get; -+ } -+ -+ public bool RangeEnded { -+ get; -+ } -+ -+ public bool RangeStarted { -+ get; -+ } -+ -+ public Selection( -+ bool isSelected, -+ bool rangeStarted, -+ bool rangeEnded -+ ) { -+ this.IsSelected = isSelected; -+ this.RangeStarted = rangeStarted; -+ this.RangeEnded = rangeEnded; -+ } -+ -+ } -+ -+ private sealed class AddressSelector { -+ -+ private bool myRangeActive; -+ private long myRangeStartLine; -+ -+ public Address? First { -+ get; -+ } -+ -+ public bool Negated { -+ get; -+ } -+ -+ public RangeEnd? Second { -+ get; -+ } -+ -+ public bool HasRange { -+ get { -+ return null != this.Second; -+ } -+ } -+ -+ public AddressSelector( -+ Address? first, -+ RangeEnd? second, -+ bool negated -+ ) { -+ if ( -+ null == first -+ && null != second -+ ) { -+ throw new ArgumentException( -+ "A range end requires a first address.", -+ nameof( second ) -+ ); -+ } -+ -+ this.First = first; -+ this.Second = second; -+ this.Negated = negated; -+ this.Reset(); -+ } -+ -+ public Selection Evaluate( -+ in AddressContext context -+ ) { -+ var rangeStarted = false; -+ var rangeEnded = false; -+ bool selected; -+ -+ if ( null == this.First ) { -+ selected = true; -+ } else if ( null == this.Second ) { -+ selected = this.First.Matches( -+ context -+ ); -+ } else if ( this.myRangeActive ) { -+ selected = true; -+ if ( -+ this.Second.IsEnd( -+ context, -+ this.myRangeStartLine, -+ isStartLine: false -+ ) -+ ) { -+ this.myRangeActive = false; -+ rangeEnded = true; -+ } -+ } else if ( -+ this.First is ZeroAddress -+ ) { -+ selected = false; -+ } else if ( -+ this.First.Matches( -+ context -+ ) -+ ) { -+ selected = true; -+ rangeStarted = true; -+ this.myRangeStartLine = context.LineNumber; -+ this.myRangeActive = !this.Second.IsEnd( -+ context, -+ this.myRangeStartLine, -+ isStartLine: true -+ ); -+ rangeEnded = !this.myRangeActive; -+ } else { -+ selected = false; -+ } -+ -+ return new Selection( -+ this.Negated -+ ? !selected -+ : selected, -+ rangeStarted, -+ rangeEnded -+ ); -+ } -+ -+ public void Reset() { -+ this.myRangeActive = this.First is ZeroAddress; -+ this.myRangeStartLine = 0; -+ } -+ -+ } -+ -+ -+} -diff --git a/sed/src/SedCapabilities.cs b/sed/src/SedCapabilities.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..6b736b8b4b3963ac1ba1d4fb9c0e60bbeb225439 ---- /dev/null -+++ b/sed/src/SedCapabilities.cs -@@ -0,0 +1,224 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.IO; -+using System.Threading; -+using System.Threading.Tasks; -+ -+// Responsibility: injectable side-effect capabilities and sandbox runtime policy. -+public static partial class Command { -+ -+ /// Executes a shell command requested by a Sed command or substitution flag. -+ internal interface ISedShellCapability { -+ -+ /// Executes one shell command through the configured process capability. -+ Task ExecuteAsync( -+ string command, -+ TextWriter output, -+ TextWriter error, -+ bool captureStandardOutput, -+ CancellationToken cancellationToken -+ ); -+ -+ } -+ -+ /// Opens files named by Sed's auxiliary read and write commands. -+ internal interface ISedAuxiliaryFileCapability { -+ -+ /// Opens an auxiliary file for asynchronous reading. -+ ValueTask OpenReadAsync( -+ string path, -+ CancellationToken cancellationToken -+ ); -+ -+ /// Creates or truncates an auxiliary file for asynchronous writing. -+ ValueTask OpenWriteAsync( -+ string path, -+ CancellationToken cancellationToken -+ ); -+ -+ } -+ -+ /// Performs command-local in-place replacement until LE10 adopts E6. -+ internal interface IInPlaceEditor { -+ -+ /// Runs one in-place transformation and publishes its temporary output. -+ Task EditAsync( -+ SedInPlaceEditRequest request, -+ Func> transformAsync, -+ CancellationToken cancellationToken -+ ); -+ -+ } -+ -+ /// Describes one command-local in-place edit request. -+ internal sealed record SedInPlaceEditRequest( -+ string Path, -+ bool FollowSymlinks, -+ string? BackupSuffix -+ ); -+ -+ /// Collects all side-effect capabilities used by one Sed invocation. -+ internal sealed class SedRuntimeCapabilities { -+ -+ /// Gets the host-backed production capability set. -+ public static SedRuntimeCapabilities System { get; } = new( -+ SystemSedShellCapability.Instance, -+ SystemSedAuxiliaryFileCapability.Instance, -+ SystemInPlaceEditor.Instance -+ ); -+ -+ /// Gets the auxiliary-file capability. -+ public ISedAuxiliaryFileCapability AuxiliaryFiles { -+ get; -+ } -+ -+ /// Gets the in-place-edit capability. -+ public IInPlaceEditor InPlaceEditor { -+ get; -+ } -+ -+ /// Gets the shell capability. -+ public ISedShellCapability Shell { -+ get; -+ } -+ -+ /// Initializes an injectable Sed capability set. -+ public SedRuntimeCapabilities( -+ ISedShellCapability shell, -+ ISedAuxiliaryFileCapability auxiliaryFiles, -+ IInPlaceEditor inPlaceEditor -+ ) { -+ this.Shell = shell ?? throw new ArgumentNullException( nameof( shell ) ); -+ this.AuxiliaryFiles = auxiliaryFiles ?? throw new ArgumentNullException( nameof( auxiliaryFiles ) ); -+ this.InPlaceEditor = inPlaceEditor ?? throw new ArgumentNullException( nameof( inPlaceEditor ) ); -+ } -+ -+ /// Returns the runtime-denied sandbox profile while retaining in-place editing. -+ public SedRuntimeCapabilities ForSandbox() { -+ return new SedRuntimeCapabilities( -+ DeniedSedShellCapability.Instance, -+ DeniedSedAuxiliaryFileCapability.Instance, -+ this.InPlaceEditor -+ ); -+ } -+ -+ } -+ -+ /// Signals that a runtime capability was denied by Sed's sandbox profile. -+ internal sealed class SedCapabilityDeniedException : InvalidOperationException { -+ -+ /// Initializes a denied-capability diagnostic. -+ public SedCapabilityDeniedException( -+ string message -+ ) : base( message ) { -+ } -+ -+ } -+ -+ /// Denies shell execution as a runtime sandbox backstop. -+ internal sealed class DeniedSedShellCapability : ISedShellCapability { -+ -+ /// Gets the singleton denied capability. -+ public static DeniedSedShellCapability Instance { get; } = new(); -+ -+ private DeniedSedShellCapability() { -+ } -+ -+ /// -+ public Task ExecuteAsync( -+ string command, -+ TextWriter output, -+ TextWriter error, -+ bool captureStandardOutput, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ throw new SedCapabilityDeniedException( -+ "shell execution is disabled in sandbox mode" -+ ); -+ } -+ -+ } -+ -+ /// Denies auxiliary reads and writes as a runtime sandbox backstop. -+ internal sealed class DeniedSedAuxiliaryFileCapability : ISedAuxiliaryFileCapability { -+ -+ /// Gets the singleton denied capability. -+ public static DeniedSedAuxiliaryFileCapability Instance { get; } = new(); -+ -+ private DeniedSedAuxiliaryFileCapability() { -+ } -+ -+ /// -+ public ValueTask OpenReadAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ throw new SedCapabilityDeniedException( -+ "auxiliary file access is disabled in sandbox mode" -+ ); -+ } -+ -+ /// -+ public ValueTask OpenWriteAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ throw new SedCapabilityDeniedException( -+ "auxiliary file access is disabled in sandbox mode" -+ ); -+ } -+ -+ } -+ -+ /// Opens host files for Sed auxiliary commands. -+ internal sealed class SystemSedAuxiliaryFileCapability : ISedAuxiliaryFileCapability { -+ -+ /// Gets the singleton host capability. -+ public static SystemSedAuxiliaryFileCapability Instance { get; } = new(); -+ -+ private SystemSedAuxiliaryFileCapability() { -+ } -+ -+ /// -+ public ValueTask OpenReadAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ return ValueTask.FromResult( -+ new FileStream( -+ path, -+ FileMode.Open, -+ FileAccess.Read, -+ FileShare.Read, -+ 8192, -+ useAsync: true -+ ) -+ ); -+ } -+ -+ /// -+ public ValueTask OpenWriteAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ return ValueTask.FromResult( -+ new FileStream( -+ path, -+ FileMode.Create, -+ FileAccess.Write, -+ FileShare.Read, -+ 8192, -+ useAsync: true -+ ) -+ ); -+ } -+ -+ } -+ -+} -diff --git a/sed/src/SedExecution.cs b/sed/src/SedExecution.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..ea82c347c86dfb9fe7d89b6d16d77cbe0a6ebcaa ---- /dev/null -+++ b/sed/src/SedExecution.cs -@@ -0,0 +1,978 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+ -+// Responsibility: command-cycle execution and deferred output. -+public static partial class Command { -+ -+ private enum DeferredOutputKind { -+ Text, -+ File -+ } -+ -+ private sealed class DeferredOutputItem { -+ -+ public DeferredOutputKind Kind { -+ get; -+ } -+ -+ public bool Terminate { -+ get; -+ } -+ -+ public string Value { -+ get; -+ } -+ -+ public DeferredOutputItem( -+ DeferredOutputKind kind, -+ string value, -+ bool terminate -+ ) { -+ this.Kind = kind; -+ this.Value = value; -+ this.Terminate = terminate; -+ } -+ -+ } -+ -+ private sealed class OutputFile : IDisposable { -+ -+ public SedOutputWriter Writer { -+ get; -+ } -+ -+ private readonly Stream myStream; -+ -+ public OutputFile( -+ Stream stream, -+ SedOutputWriter writer -+ ) { -+ this.myStream = stream; -+ this.Writer = writer; -+ } -+ -+ public void Dispose() { -+ this.myStream.Dispose(); -+ } -+ -+ } -+ -+ private sealed class ExecutionEnvironment : IDisposable { -+ -+ private readonly List myDeferredOutput; -+ private readonly Dictionary myReadLineFiles; -+ private readonly Dictionary myWriteFiles; -+ -+ public bool Debug { -+ get; -+ } -+ -+ public ISedAuxiliaryFileCapability AuxiliaryFiles { -+ get; -+ } -+ -+ public TextWriter Error { -+ get; -+ } -+ -+ public string HoldSpace { -+ get; -+ set; -+ } = string.Empty; -+ -+ public bool HoldSpaceTerminated { -+ get; -+ set; -+ } = true; -+ -+ public int ListWidth { -+ get; -+ } -+ -+ public bool NullData { -+ get; -+ } -+ -+ public char PatternSeparator => this.NullData ? '\0' : '\n'; -+ -+ public SedOutputWriter Output { -+ get; -+ } -+ -+ public bool SuppressAutomaticPrint { -+ get; -+ } -+ -+ public ISedShellCapability Shell { -+ get; -+ } -+ -+ public SedTextCodec TextCodec { -+ get; -+ } -+ -+ public ExecutionEnvironment( -+ Stream output, -+ SedTextCodec textCodec, -+ TextWriter error, -+ bool suppressAutomaticPrint, -+ bool nullData, -+ int listWidth, -+ bool debug, -+ bool unbuffered, -+ ISedShellCapability shell, -+ ISedAuxiliaryFileCapability auxiliaryFiles -+ ) : this( -+ new SedOutputWriter( output, textCodec, nullData ) { -+ AutoFlush = unbuffered -+ }, -+ textCodec, -+ error, -+ suppressAutomaticPrint, -+ nullData, -+ listWidth, -+ debug, -+ shell, -+ auxiliaryFiles -+ ) { -+ } -+ -+ public ExecutionEnvironment( -+ SedOutputWriter output, -+ SedTextCodec textCodec, -+ TextWriter error, -+ bool suppressAutomaticPrint, -+ bool nullData, -+ int listWidth, -+ bool debug, -+ ISedShellCapability shell, -+ ISedAuxiliaryFileCapability auxiliaryFiles -+ ) { -+ this.TextCodec = textCodec ?? throw new ArgumentNullException( nameof( textCodec ) ); -+ this.Output = output ?? throw new ArgumentNullException( nameof( output ) ); -+ this.Error = error ?? throw new ArgumentNullException( nameof( error ) ); -+ this.Shell = shell ?? throw new ArgumentNullException( nameof( shell ) ); -+ this.AuxiliaryFiles = auxiliaryFiles ?? throw new ArgumentNullException( nameof( auxiliaryFiles ) ); -+ this.SuppressAutomaticPrint = suppressAutomaticPrint; -+ this.Debug = debug; -+ this.NullData = nullData; -+ this.ListWidth = listWidth; -+ this.myDeferredOutput = new List(); -+ this.myReadLineFiles = new Dictionary( StringComparer.Ordinal ); -+ this.myWriteFiles = new Dictionary( StringComparer.Ordinal ); -+ } -+ -+ public void ClearDeferredOutput() { -+ this.myDeferredOutput.Clear(); -+ } -+ -+ public void Defer( -+ string value, -+ bool terminate = true -+ ) { -+ this.myDeferredOutput.Add( -+ new DeferredOutputItem( DeferredOutputKind.Text, value, terminate ) -+ ); -+ } -+ -+ public void DeferFile( -+ string fileName -+ ) { -+ this.myDeferredOutput.Add( -+ new DeferredOutputItem( DeferredOutputKind.File, fileName, terminate: false ) -+ ); -+ } -+ -+ public async Task DeferFileLineAsync( -+ string fileName, -+ CancellationToken cancellationToken -+ ) { -+ try { -+ if ( !this.myReadLineFiles.TryGetValue( fileName, out var reader ) ) { -+ var stream = await this.AuxiliaryFiles.OpenReadAsync( -+ fileName, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ reader = new AsyncRecordReader( -+ stream, -+ this.NullData, -+ ownsStream: true, -+ this.TextCodec, -+ new SedInputSourceIdentity( 0, fileName, isStandardInput: false ) -+ ); -+ this.myReadLineFiles.Add( fileName, reader ); -+ } -+ var line = await reader.ReadAsync( 1, cancellationToken ).ConfigureAwait( false ); -+ if ( null != line ) { -+ this.Defer( line.Text, line.IsTerminated ); -+ } -+ } catch ( OperationCanceledException ) { -+ throw; -+ } catch ( SedCapabilityDeniedException ) { -+ throw; -+ } catch ( Exception ex ) { -+ await this.Error.WriteLineAsync( $"sed: {fileName}: {ex.Message}" ).ConfigureAwait( false ); -+ } -+ } -+ -+ public async Task FlushDeferredOutputAsync( -+ CancellationToken cancellationToken -+ ) { -+ foreach ( var item in this.myDeferredOutput ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ if ( DeferredOutputKind.Text == item.Kind ) { -+ await this.Output.WriteRecordAsync( item.Value, item.Terminate, cancellationToken ).ConfigureAwait( false ); -+ continue; -+ } -+ try { -+ await this.Output.BeginOutputAsync( cancellationToken ).ConfigureAwait( false ); -+ using var stream = await this.AuxiliaryFiles.OpenReadAsync( -+ item.Value, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ using var reader = new AsyncRecordReader( -+ stream, -+ this.NullData, -+ ownsStream: false, -+ this.TextCodec, -+ new SedInputSourceIdentity( 0, item.Value, isStandardInput: false ) -+ ); -+ long recordNumber = 0; -+ SedInputRecord? record; -+ while ( null != ( record = await reader.ReadAsync( ++recordNumber, cancellationToken ).ConfigureAwait( false ) ) ) { -+ await this.Output.WriteRecordAsync( record.Text, record.IsTerminated, cancellationToken ).ConfigureAwait( false ); -+ } -+ } catch ( OperationCanceledException ) { -+ throw; -+ } catch ( SedCapabilityDeniedException ) { -+ throw; -+ } catch ( Exception ex ) { -+ await this.Error.WriteLineAsync( $"sed: {item.Value}: {ex.Message}" ).ConfigureAwait( false ); -+ } -+ } -+ this.myDeferredOutput.Clear(); -+ } -+ -+ public async Task WriteFileAsync( -+ string fileName, -+ string value, -+ bool terminate, -+ CancellationToken cancellationToken -+ ) { -+ if ( !this.myWriteFiles.TryGetValue( fileName, out var outputFile ) ) { -+ var stream = await this.AuxiliaryFiles.OpenWriteAsync( -+ fileName, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ outputFile = new OutputFile( -+ stream, -+ new SedOutputWriter( stream, this.TextCodec, this.NullData ) -+ ); -+ this.myWriteFiles.Add( fileName, outputFile ); -+ } -+ await outputFile.Writer.WriteRecordAsync( value, terminate, cancellationToken ).ConfigureAwait( false ); -+ await outputFile.Writer.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ public async Task DisposeAsync( -+ CancellationToken cancellationToken -+ ) { -+ foreach ( var outputFile in this.myWriteFiles.Values ) { -+ await outputFile.Writer.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ await this.Output.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ this.Dispose(); -+ } -+ -+ public void Dispose() { -+ foreach ( var reader in this.myReadLineFiles.Values ) { -+ reader.Dispose(); -+ } -+ this.myReadLineFiles.Clear(); -+ foreach ( var writer in this.myWriteFiles.Values ) { -+ writer.Dispose(); -+ } -+ this.myWriteFiles.Clear(); -+ } -+ -+ } -+ -+ /// Reports whether execution requested termination and its exit status. -+ internal sealed class ExecutionResult { -+ -+ /// Gets the requested process exit status. -+ public int ExitCode { -+ get; -+ } -+ -+ /// Gets whether execution requested command termination. -+ public bool Quit { -+ get; -+ } -+ -+ /// Initializes one execution result. -+ public ExecutionResult( -+ bool quit, -+ int exitCode -+ ) { -+ this.Quit = quit; -+ this.ExitCode = exitCode; -+ } -+ -+ } -+ -+ private static async Task ExecuteAsync( -+ SedProgram program, -+ InputSequence input, -+ ExecutionEnvironment environment, -+ CancellationToken cancellationToken -+ ) { -+ program.ResetAddresses(); -+ -+ while ( -+ await input.MoveNextAsync( -+ cancellationToken -+ ).ConfigureAwait( false ) -+ ) { -+ var patternSpace = input.Current.Text; -+ var patternTerminated = input.Current.IsTerminated; -+ if ( environment.Debug ) { -+ await environment.Error.WriteLineAsync( -+ $"INPUT: {input.LineNumber}" -+ ).ConfigureAwait( false ); -+ await environment.Error.WriteLineAsync( -+ $"PATTERN: {EscapeDebugText( patternSpace )}" -+ ).ConfigureAwait( false ); -+ } -+ var substitutionSucceeded = false; -+ var automaticPrint = true; -+ var programCounter = 0; -+ environment.ClearDeferredOutput(); -+ -+ while ( programCounter < program.Instructions.Count ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ -+ var instruction = program.Instructions[ programCounter ]; -+ if ( InstructionKind.Label == instruction.Kind ) { -+ programCounter++; -+ continue; -+ } else if ( InstructionKind.EndGroup == instruction.Kind ) { -+ programCounter++; -+ continue; -+ } -+ -+ var context = new AddressContext( -+ input.LineNumber, -+ input.IsLast, -+ patternSpace, -+ cancellationToken -+ ); -+ var selection = instruction.Address?.Evaluate( -+ context -+ ) ?? new Selection( -+ isSelected: true, -+ rangeStarted: false, -+ rangeEnded: false -+ ); -+ -+ if ( InstructionKind.BeginGroup == instruction.Kind ) { -+ programCounter = selection.IsSelected -+ ? programCounter + 1 -+ : instruction.JumpIndex -+ ; -+ continue; -+ } -+ -+ if ( !selection.IsSelected ) { -+ programCounter++; -+ continue; -+ } -+ -+ switch ( instruction.Kind ) { -+ case InstructionKind.AppendText: { -+ environment.Defer( -+ instruction.Argument as string -+ ?? string.Empty -+ ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.AppendHold: { -+ if ( instruction.Argument is bool ) { -+ environment.HoldSpace = string.Concat( -+ environment.HoldSpace, -+ environment.PatternSeparator.ToString(), -+ patternSpace -+ ); -+ environment.HoldSpaceTerminated = patternTerminated; -+ } else { -+ patternSpace = string.Concat( -+ patternSpace, -+ environment.PatternSeparator.ToString(), -+ environment.HoldSpace -+ ); -+ patternTerminated = environment.HoldSpaceTerminated; -+ } -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.AppendNext: { -+ if ( -+ !await input.MoveNextAsync( -+ cancellationToken -+ ).ConfigureAwait( false ) -+ ) { -+ if ( -+ automaticPrint -+ && !environment.SuppressAutomaticPrint -+ ) { -+ await WriteRecordAsync( -+ environment.Output, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ await environment.FlushDeferredOutputAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new ExecutionResult( -+ quit: false, -+ exitCode: 0 -+ ); -+ } -+ patternSpace = string.Concat( -+ patternSpace, -+ environment.PatternSeparator.ToString(), -+ input.Current.Text -+ ); -+ patternTerminated = input.Current.IsTerminated; -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.Branch: { -+ programCounter = program.ResolveLabel( -+ instruction.Argument as string -+ ); -+ break; -+ } -+ -+ case InstructionKind.ChangeText: { -+ if ( -+ null == instruction.Address -+ || !instruction.Address.HasRange -+ || instruction.Address.Negated -+ || selection.RangeStarted -+ ) { -+ await WriteRecordAsync( -+ environment.Output, -+ instruction.Argument as string -+ ?? string.Empty, -+ terminate: true, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ automaticPrint = false; -+ programCounter = program.Instructions.Count; -+ break; -+ } -+ -+ case InstructionKind.Delete: { -+ automaticPrint = false; -+ programCounter = program.Instructions.Count; -+ break; -+ } -+ -+ case InstructionKind.DeleteFirst: { -+ var newline = patternSpace.IndexOf( -+ environment.PatternSeparator -+ ); -+ if ( newline < 0 ) { -+ automaticPrint = false; -+ programCounter = program.Instructions.Count; -+ } else { -+ patternSpace = patternSpace.Substring( -+ newline + 1 -+ ); -+ substitutionSucceeded = false; -+ programCounter = 0; -+ } -+ break; -+ } -+ -+ case InstructionKind.Execute: { -+ var commandText = instruction.Argument as string; -+ if ( string.IsNullOrWhiteSpace( commandText ) ) { -+ commandText = patternSpace; -+ } -+ var shellResult = await ExecuteShellAsync( -+ commandText, -+ environment, -+ captureStandardOutput: false, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( shellResult.ExitCode != 0 ) { -+ await environment.Error.WriteLineAsync( -+ $"sed: command exited with status {shellResult.ExitCode}" -+ ).ConfigureAwait( false ); -+ } -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.Exchange: { -+ var value = patternSpace; -+ var valueTerminated = patternTerminated; -+ patternSpace = environment.HoldSpace; -+ patternTerminated = environment.HoldSpaceTerminated; -+ environment.HoldSpace = value; -+ environment.HoldSpaceTerminated = valueTerminated; -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.GetHold: { -+ patternSpace = environment.HoldSpace; -+ patternTerminated = environment.HoldSpaceTerminated; -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.LineNumber: { -+ await WriteRecordAsync( -+ environment.Output, -+ input.LineNumber.ToString( -+ CultureInfo.InvariantCulture -+ ), -+ terminate: true, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.List: { -+ var width = instruction.Argument is int configuredWidth -+ ? configuredWidth -+ : environment.ListWidth -+ ; -+ await WriteRecordAsync( -+ environment.Output, -+ FormatList( -+ patternSpace, -+ width -+ ), -+ terminate: true, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.Next: { -+ if ( !environment.SuppressAutomaticPrint ) { -+ await WriteRecordAsync( -+ environment.Output, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ await environment.FlushDeferredOutputAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( -+ !await input.MoveNextAsync( -+ cancellationToken -+ ).ConfigureAwait( false ) -+ ) { -+ return new ExecutionResult( -+ quit: false, -+ exitCode: 0 -+ ); -+ } -+ patternSpace = input.Current.Text; -+ patternTerminated = input.Current.IsTerminated; -+ substitutionSucceeded = false; -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.Print: { -+ if ( instruction.Argument is InsertArgument insert ) { -+ await WriteRecordAsync( -+ environment.Output, -+ insert.Text, -+ terminate: true, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } else { -+ await WriteRecordAsync( -+ environment.Output, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.PrintFirst: { -+ await WriteRecordAsync( -+ environment.Output, -+ FirstPatternLine( -+ patternSpace, -+ environment.PatternSeparator -+ ), -+ 0 <= patternSpace.IndexOf( environment.PatternSeparator ) || patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.Quit: { -+ if ( !environment.SuppressAutomaticPrint ) { -+ await WriteRecordAsync( -+ environment.Output, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ await environment.FlushDeferredOutputAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ return new ExecutionResult( -+ quit: true, -+ exitCode: instruction.Argument is int configuredExitCode -+ ? configuredExitCode -+ : 0 -+ ); -+ } -+ -+ case InstructionKind.QuitSilent: { -+ return new ExecutionResult( -+ quit: true, -+ exitCode: instruction.Argument is int configuredExitCode -+ ? configuredExitCode -+ : 0 -+ ); -+ } -+ -+ case InstructionKind.ReadFile: { -+ environment.DeferFile( -+ instruction.Argument as string -+ ?? string.Empty -+ ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.ReadFileLine: { -+ await environment.DeferFileLineAsync( -+ instruction.Argument as string -+ ?? string.Empty, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.SetHold: { -+ environment.HoldSpace = patternSpace; -+ environment.HoldSpaceTerminated = patternTerminated; -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.Substitute: { -+ var substitution = instruction.Argument as Substitution -+ ?? throw new InvalidOperationException() -+ ; -+ var result = ApplySubstitution( -+ patternSpace, -+ substitution, -+ out var replaced, -+ cancellationToken -+ ); -+ if ( replaced ) { -+ patternSpace = result; -+ substitutionSucceeded = true; -+ var flags = ParseSubstitutionFlags( -+ substitution.Flags -+ ); -+ if ( flags.Execute ) { -+ var shellResult = await ExecuteShellAsync( -+ patternSpace, -+ environment, -+ captureStandardOutput: true, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ patternSpace = shellResult.StandardOutput.TrimEnd( -+ '\r', -+ '\n' -+ ); -+ if ( shellResult.ExitCode != 0 ) { -+ await environment.Error.WriteLineAsync( -+ $"sed: command exited with status {shellResult.ExitCode}" -+ ).ConfigureAwait( false ); -+ } -+ } -+ if ( flags.Print ) { -+ await WriteRecordAsync( -+ environment.Output, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ if ( !string.IsNullOrEmpty( flags.WriteFile ) ) { -+ await environment.WriteFileAsync( -+ flags.WriteFile, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ } -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.TestBranch: { -+ var branch = substitutionSucceeded; -+ substitutionSucceeded = false; -+ programCounter = branch -+ ? program.ResolveLabel( -+ instruction.Argument as string -+ ) -+ : programCounter + 1 -+ ; -+ break; -+ } -+ -+ case InstructionKind.TestNoBranch: { -+ var branch = !substitutionSucceeded; -+ substitutionSucceeded = false; -+ programCounter = branch -+ ? program.ResolveLabel( -+ instruction.Argument as string -+ ) -+ : programCounter + 1 -+ ; -+ break; -+ } -+ -+ case InstructionKind.Transliterate: { -+ patternSpace = Transliterate( -+ patternSpace, -+ instruction.Argument as Transliteration -+ ?? throw new InvalidOperationException() -+ ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.WriteFile: { -+ await environment.WriteFileAsync( -+ instruction.Argument as string -+ ?? string.Empty, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ programCounter++; -+ break; -+ } -+ -+ case InstructionKind.WriteFirst: { -+ await environment.WriteFileAsync( -+ instruction.Argument as string -+ ?? string.Empty, -+ FirstPatternLine( -+ patternSpace, -+ environment.PatternSeparator -+ ), -+ 0 <= patternSpace.IndexOf( environment.PatternSeparator ) || patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ programCounter++; -+ break; -+ } -+ -+ default: -+ throw new InvalidOperationException( -+ $"Unhandled instruction {instruction.Kind}." -+ ); -+ } -+ } -+ -+ if ( -+ automaticPrint -+ && !environment.SuppressAutomaticPrint -+ ) { -+ await WriteRecordAsync( -+ environment.Output, -+ patternSpace, -+ patternTerminated, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ await environment.FlushDeferredOutputAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ return new ExecutionResult( -+ quit: false, -+ exitCode: 0 -+ ); -+ } -+ -+ private static string EscapeDebugText( -+ string value -+ ) { -+ return value -+ .Replace( "\\", "\\\\", StringComparison.Ordinal ) -+ .Replace( "\r", "\\r", StringComparison.Ordinal ) -+ .Replace( "\n", "\\n", StringComparison.Ordinal ) -+ .Replace( "\0", "\\0", StringComparison.Ordinal ) -+ ; -+ } -+ -+ -+ -+ private static string FormatList( -+ string value, -+ int width -+ ) { -+ var escaped = new StringBuilder(); -+ foreach ( var character in value ) { -+ switch ( character ) { -+ case '\\': -+ escaped.Append( -+ "\\\\" -+ ); -+ break; -+ case '\a': -+ escaped.Append( -+ "\\a" -+ ); -+ break; -+ case '\b': -+ escaped.Append( -+ "\\b" -+ ); -+ break; -+ case '\f': -+ escaped.Append( -+ "\\f" -+ ); -+ break; -+ case '\0': -+ escaped.Append( -+ "\\000" -+ ); -+ break; -+ case '\n': -+ escaped.Append( -+ "\\n" -+ ); -+ break; -+ case '\r': -+ escaped.Append( -+ "\\r" -+ ); -+ break; -+ case '\t': -+ escaped.Append( -+ "\\t" -+ ); -+ break; -+ default: -+ if ( -+ char.IsControl( -+ character -+ ) -+ ) { -+ escaped.AppendFormat( -+ CultureInfo.InvariantCulture, -+ "\\x{0:X2}", -+ (int)character -+ ); -+ } else { -+ escaped.Append( -+ character -+ ); -+ } -+ break; -+ } -+ } -+ escaped.Append( -+ '$' -+ ); -+ -+ if ( -+ width <= 0 -+ || escaped.Length <= width -+ ) { -+ return escaped.ToString(); -+ } -+ -+ var output = new StringBuilder(); -+ var index = 0; -+ while ( index < escaped.Length ) { -+ var count = Math.Min( -+ width, -+ escaped.Length - index -+ ); -+ output.Append( -+ escaped, -+ index, -+ count -+ ); -+ index += count; -+ if ( index < escaped.Length ) { -+ output.Append( -+ "\\\n" -+ ); -+ } -+ } -+ return output.ToString(); -+ } -+ -+ private static string FirstPatternLine( -+ string patternSpace, -+ char separator -+ ) { -+ var index = patternSpace.IndexOf( -+ separator -+ ); -+ return index < 0 -+ ? patternSpace -+ : patternSpace.Substring( -+ 0, -+ index -+ ) -+ ; -+ } -+ -+ -+} -diff --git a/sed/src/SedFiles.cs b/sed/src/SedFiles.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..7b8bfe05ac3a3ce31feee169f1edd4000d825e1d ---- /dev/null -+++ b/sed/src/SedFiles.cs -@@ -0,0 +1,235 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.IO; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.FileSystem; -+using Icod.CommandFramework.FileSystem.Metadata; -+using Icod.CommandFramework.FileSystem.Mutation; -+using Icod.CommandFramework.FileSystem.RecursiveMutation; -+using Icod.CommandFramework.FileSystem.TransactionalReplacement; -+using Icod.CommandFramework.FileSystem.Traversal; -+using Icod.CommandFramework.Temporary; -+ -+// Responsibility: in-place editing and pathname handling. -+public static partial class Command { -+ private static Task ProcessInPlaceAsync( -+ string path, -+ Options options, -+ SedProgram program, -+ SedTextCodec textCodec, -+ TextWriter stderr, -+ SedRuntimeCapabilities capabilities, -+ CancellationToken cancellationToken -+ ) { -+ return capabilities.InPlaceEditor.EditAsync( -+ new SedInPlaceEditRequest( -+ path, -+ options.FollowSymlinks, -+ options.BackupSuffix -+ ), -+ async ( -+ editPath, -+ outputStream, -+ transformCancellationToken -+ ) => { -+ using var input = new InputSequence( -+ new SourceSpec[] { new SourceSpec( editPath ) }, -+ Stream.Null, -+ options.NullData, -+ textCodec -+ ); -+ var environment = new ExecutionEnvironment( -+ outputStream, -+ textCodec, -+ stderr, -+ options.SuppressAutomaticPrint, -+ options.NullData, -+ options.ListWidth, -+ options.Debug, -+ options.Unbuffered, -+ capabilities.Shell, -+ capabilities.AuxiliaryFiles -+ ); -+ try { -+ return await ExecuteAsync( -+ program, -+ input, -+ environment, -+ transformCancellationToken -+ ).ConfigureAwait( false ); -+ } finally { -+ await environment.DisposeAsync( -+ transformCancellationToken -+ ).ConfigureAwait( false ); -+ } -+ }, -+ cancellationToken -+ ); -+ } -+ -+ /// -+ /// Implements Sed in-place replacement through the shared E6 transaction model. -+ /// -+ internal sealed class SystemInPlaceEditor : IInPlaceEditor { -+ private const RecursiveMetadataFields ReplacementMetadata = -+ RecursiveMetadataFields.Mode -+ | RecursiveMetadataFields.Ownership -+ | RecursiveMetadataFields.Attributes; -+ -+ private readonly ITransactionalReplacementFileSystem myFileSystem; -+ private readonly ITransactionalReplacementFailureInjector myFailureInjector; -+ -+ /// Gets the host-backed singleton editor. -+ public static SystemInPlaceEditor Instance { get; } = new( -+ SystemTransactionalReplacementFileSystem.Instance, -+ NullTransactionalReplacementFailureInjector.Instance -+ ); -+ -+ /// Initializes an editor over an injectable secure temporary-object creator. -+ public SystemInPlaceEditor( -+ SecureTemporaryObjectCreator temporaryObjects -+ ) : this( -+ new SystemTransactionalReplacementFileSystem( -+ SystemFileSystemMetadataProvider.Instance, -+ SystemFileSystemMutationProvider.Instance, -+ SystemFileSystemOperations.Instance, -+ temporaryObjects -+ ), -+ NullTransactionalReplacementFailureInjector.Instance -+ ) { -+ } -+ -+ /// Initializes an editor over an injectable E6 filesystem and failure boundary. -+ public SystemInPlaceEditor( -+ ITransactionalReplacementFileSystem fileSystem, -+ ITransactionalReplacementFailureInjector? failureInjector = null -+ ) { -+ ArgumentNullException.ThrowIfNull( fileSystem ); -+ this.myFileSystem = fileSystem; -+ this.myFailureInjector = failureInjector -+ ?? NullTransactionalReplacementFailureInjector.Instance; -+ } -+ -+ /// -+ public async Task EditAsync( -+ SedInPlaceEditRequest request, -+ Func> transformAsync, -+ CancellationToken cancellationToken -+ ) { -+ ArgumentNullException.ThrowIfNull( request ); -+ ArgumentNullException.ThrowIfNull( transformAsync ); -+ cancellationToken.ThrowIfCancellationRequested(); -+ var editPath = System.IO.Path.GetFullPath( -+ ResolveInPlacePath( request.Path, request.FollowSymlinks ) -+ ); -+ var observation = await this.myFileSystem.ObserveAsync( -+ editPath, -+ PathDereferenceMode.NoFollow, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( !observation.Exists || null == observation.Metadata ) { -+ throw new FileNotFoundException( -+ "The in-place input file does not exist.", -+ editPath -+ ); -+ } -+ var metadata = observation.Metadata; -+ var precondition = FileSystemMutationPrecondition.FromObservation( -+ metadata.Kind, -+ metadata.EntryIdentity, -+ PathDereferenceMode.NoFollow -+ ); -+ var metadataPlan = RecursiveMetadataPreservationPlan.Create( -+ metadata, -+ ReplacementMetadata, -+ RecursiveMetadataFields.None -+ ); -+ ExecutionResult? executionResult = null; -+ var hasBackup = !string.IsNullOrEmpty( request.BackupSuffix ); -+ var backupPath = hasBackup -+ ? BuildBackupPath( editPath, request.BackupSuffix! ) -+ : null; -+ var artifact = new TransactionalReplacementArtifact( -+ recoveryUnitId: "sed-in-place", -+ path: editPath, -+ action: TransactionalReplacementAction.Replace, -+ precondition: precondition, -+ contentWriter: async ( destination, token ) => { -+ executionResult = await transformAsync( -+ editPath, -+ destination, -+ token -+ ).ConfigureAwait( false ); -+ }, -+ displayName: request.Path, -+ sourceMetadata: metadata, -+ metadataPlan: metadataPlan, -+ explicitBackupPath: backupPath, -+ retainBackup: hasBackup -+ ); -+ await using var transaction = new TransactionalFileReplacementTransaction( -+ new TransactionalReplacementArtifact[] { artifact }, -+ this.myFileSystem, -+ TransactionalReplacementOptions.Default, -+ failureInjector: this.myFailureInjector -+ ); -+ var transactionResult = await transaction.CommitAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( !transactionResult.Succeeded ) { -+ throw CreateTransactionException( "sed in-place edit", transactionResult ); -+ } -+ return executionResult -+ ?? throw new IOException( "The in-place transform produced no execution result." ); -+ } -+ -+ private static IOException CreateTransactionException( -+ string operation, -+ TransactionalReplacementResult result -+ ) { -+ var diagnostic = 0 == result.Diagnostics.Count -+ ? null -+ : result.Diagnostics[ result.Diagnostics.Count - 1 ]; -+ return new IOException( -+ null == diagnostic -+ ? $"{operation} failed with outcome {result.Outcome}." -+ : diagnostic.Message, -+ diagnostic?.Exception -+ ); -+ } -+ } -+ -+ private static string BuildBackupPath( -+ string path, -+ string suffix -+ ) { -+ return suffix.Contains( -+ "*", -+ StringComparison.Ordinal -+ ) -+ ? suffix.Replace( -+ "*", -+ path, -+ StringComparison.Ordinal -+ ) -+ : string.Concat( -+ path, -+ suffix -+ ) -+ ; -+ } -+ -+ private static string ResolveInPlacePath( -+ string path, -+ bool followSymlinks -+ ) { -+ if ( !followSymlinks ) { -+ return path; -+ } -+ var info = new FileInfo( path ); -+ var target = info.ResolveLinkTarget( returnFinalTarget: true ); -+ return target?.FullName ?? path; -+ } -+} -diff --git a/sed/src/SedOptions.cs b/sed/src/SedOptions.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..b285ba7fb01823baa50086408775fd8954232557 ---- /dev/null -+++ b/sed/src/SedOptions.cs -@@ -0,0 +1,380 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+ -+// Responsibility: command-line option parsing and usage presentation. -+public static partial class Command { -+ -+ private sealed class Options { -+ -+ public bool Debug { -+ get; -+ set; -+ } -+ -+ public bool ExtendedRegularExpressions { -+ get; -+ set; -+ } -+ -+ public bool FollowSymlinks { -+ get; -+ set; -+ } -+ -+ public bool InPlace { -+ get; -+ set; -+ } -+ -+ public bool Posix { -+ get; -+ set; -+ } -+ -+ public string? BackupSuffix { -+ get; -+ set; -+ } -+ -+ public int ListWidth { -+ get; -+ set; -+ } = DefaultListWidth; -+ -+ public bool NullData { -+ get; -+ set; -+ } -+ -+ public bool Sandbox { -+ get; -+ set; -+ } -+ -+ public bool Separate { -+ get; -+ set; -+ } -+ -+ public bool SuppressAutomaticPrint { -+ get; -+ set; -+ } -+ -+ public bool Unbuffered { -+ get; -+ set; -+ } -+ -+ } -+ -+ private static async Task ParseArgumentsAsync( -+ string[] args, -+ Options options, -+ ICollection scripts, -+ ICollection files, -+ TextWriter stdout, -+ TextWriter stderr, -+ CancellationToken cancellationToken -+ ) { -+ var parser = new OptionParser( -+ new OptionDefinition[] { -+ new OptionDefinition( "quiet", 'n', new string[] { "quiet", "silent" } ), -+ new OptionDefinition( "debug", longNames: new string[] { "debug" } ), -+ new OptionDefinition( "expression", 'e', new string[] { "expression" }, OptionValueArity.Required ), -+ new OptionDefinition( "file", 'f', new string[] { "file" }, OptionValueArity.Required ), -+ new OptionDefinition( "follow-symlinks", longNames: new string[] { "follow-symlinks" } ), -+ new OptionDefinition( "in-place", 'i', new string[] { "in-place" }, OptionValueArity.Optional ), -+ new OptionDefinition( "line-length", 'l', new string[] { "line-length" }, OptionValueArity.Required ), -+ new OptionDefinition( "posix", longNames: new string[] { "posix" } ), -+ new OptionDefinition( "regexp-extended", 'E', new string[] { "regexp-extended" } ), -+ new OptionDefinition( "regexp-extended-r", 'r' ), -+ new OptionDefinition( "separate", 's', new string[] { "separate" } ), -+ new OptionDefinition( "sandbox", longNames: new string[] { "sandbox" } ), -+ new OptionDefinition( "unbuffered", 'u', new string[] { "unbuffered" } ), -+ new OptionDefinition( "null-data", 'z', new string[] { "null-data" } ), -+ new OptionDefinition( "help", '?', new string[] { "help" } ), -+ new OptionDefinition( "version", 'V', new string[] { "version" } ) -+ }, -+ new OptionParserSettings { -+ AllowLongOptionAbbreviations = true, -+ Ordering = OptionOrdering.Permute -+ } -+ ); -+ var result = parser.Parse( -+ args -+ ); -+ if ( !result.IsSuccess ) { -+ foreach ( var error in result.Errors ) { -+ await stderr.WriteLineAsync( -+ OptionDiagnosticFormatter.Format( -+ "sed", -+ error -+ ) -+ ).ConfigureAwait( false ); -+ } -+ return UsageExitCode; -+ } -+ -+ foreach ( var occurrence in result.Options ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ switch ( occurrence.Definition.Key ) { -+ case "quiet": -+ options.SuppressAutomaticPrint = true; -+ break; -+ case "debug": -+ options.Debug = true; -+ break; -+ case "expression": { -+ var order = scripts.Count; -+ scripts.Add( -+ new SedScriptSource( -+ SedScriptSourceKind.Expression, -+ $"-e expression #{order + 1}", -+ occurrence.Value ?? string.Empty, -+ order -+ ) -+ ); -+ break; -+ } -+ case "file": { -+ var path = occurrence.Value ?? string.Empty; -+ var order = scripts.Count; -+ scripts.Add( -+ new SedScriptSource( -+ SedScriptSourceKind.File, -+ path, -+ await ReadScriptFileAsync( -+ path, -+ cancellationToken -+ ).ConfigureAwait( false ), -+ order -+ ) -+ ); -+ break; -+ } -+ case "follow-symlinks": -+ options.FollowSymlinks = true; -+ break; -+ case "in-place": -+ options.InPlace = true; -+ options.BackupSuffix = occurrence.Value ?? string.Empty; -+ break; -+ case "line-length": -+ if ( -+ !int.TryParse( -+ occurrence.Value, -+ NumberStyles.None, -+ CultureInfo.InvariantCulture, -+ out var listWidth -+ ) -+ || listWidth <= 0 -+ ) { -+ await stderr.WriteLineAsync( -+ "sed: option --line-length requires a positive integer" -+ ).ConfigureAwait( false ); -+ return UsageExitCode; -+ } -+ options.ListWidth = listWidth; -+ break; -+ case "posix": -+ options.Posix = true; -+ break; -+ case "regexp-extended": -+ case "regexp-extended-r": -+ options.ExtendedRegularExpressions = true; -+ break; -+ case "separate": -+ options.Separate = true; -+ break; -+ case "sandbox": -+ options.Sandbox = true; -+ break; -+ case "unbuffered": -+ options.Unbuffered = true; -+ break; -+ case "null-data": -+ options.NullData = true; -+ break; -+ case "help": -+ await PrintUsageAsync( -+ stdout -+ ).ConfigureAwait( false ); -+ return CommandExitCodes.Success; -+ case "version": -+ await stdout.WriteLineAsync( -+ VersionText -+ ).ConfigureAwait( false ); -+ return CommandExitCodes.Success; -+ } -+ } -+ -+ foreach ( var operand in result.Operands ) { -+ files.Add( -+ operand -+ ); -+ } -+ return null; -+ } -+ -+ private static async Task PrintUsageAsync( -+ TextWriter stdout -+ ) { -+ using ( var buffer = new StringWriter( -+ CultureInfo.InvariantCulture -+ ) ) { -+ PrintUsage( -+ buffer -+ ); -+ await stdout.WriteAsync( -+ buffer.ToString() -+ ).ConfigureAwait( false ); -+ } -+ } -+ -+ private static void PrintUsage( -+ TextWriter stdout -+ ) { -+ stdout.WriteLine( -+ "Usage: sed [OPTION]... {script-only-if-no-other-script} [input-file]..." -+ ); -+ stdout.WriteLine( -+ " -?, --help display this help" -+ ); -+ stdout.WriteLine( -+ " -V, --version display version information" -+ ); -+ stdout.WriteLine( -+ " -n, --quiet, --silent suppress automatic printing" -+ ); -+ stdout.WriteLine( -+ " --debug annotate program execution" -+ ); -+ stdout.WriteLine( -+ " -e SCRIPT add SCRIPT to the program" -+ ); -+ stdout.WriteLine( -+ " -f FILE add commands from script FILE" -+ ); -+ stdout.WriteLine( -+ " -i[SUFFIX] edit files in place; optionally back up" -+ ); -+ stdout.WriteLine( -+ " --follow-symlinks follow symlinks when editing in place" -+ ); -+ stdout.WriteLine( -+ " --posix disable GNU extensions" -+ ); -+ stdout.WriteLine( -+ " -E, -r use extended regular expressions" -+ ); -+ stdout.WriteLine( -+ " -s, --separate treat input files separately" -+ ); -+ stdout.WriteLine( -+ " -u, --unbuffered flush output more frequently" -+ ); -+ stdout.WriteLine( -+ " -z, --null-data separate records with NUL" -+ ); -+ stdout.WriteLine( -+ " -l N, --line-length=N set the l-command wrap width" -+ ); -+ stdout.WriteLine( -+ " --sandbox disable e, r, R, w, W, and s///e" -+ ); -+ stdout.WriteLine(); -+ stdout.WriteLine( -+ "Addresses:" -+ ); -+ stdout.WriteLine( -+ " N line N; $ last line; /expr/ matching pattern space" -+ ); -+ stdout.WriteLine( -+ " M,N inclusive address range; append ! to negate" -+ ); -+ stdout.WriteLine( -+ " F~S every Sth line beginning with F" -+ ); -+ stdout.WriteLine( -+ " A,+N address A and the following N lines" -+ ); -+ stdout.WriteLine( -+ " A,~N address A through the next line-number multiple of N" -+ ); -+ stdout.WriteLine(); -+ stdout.WriteLine( -+ "Commands:" -+ ); -+ stdout.WriteLine( -+ " = print input line number" -+ ); -+ stdout.WriteLine( -+ " a TEXT append TEXT after the current cycle" -+ ); -+ stdout.WriteLine( -+ " b LABEL branch unconditionally" -+ ); -+ stdout.WriteLine( -+ " c TEXT replace selected pattern spaces with TEXT" -+ ); -+ stdout.WriteLine( -+ " d, D delete pattern space / delete through first newline" -+ ); -+ stdout.WriteLine( -+ " e [CMD] execute CMD, or execute pattern space when omitted" -+ ); -+ stdout.WriteLine( -+ " g,G,h,H,x manipulate pattern and hold spaces" -+ ); -+ stdout.WriteLine( -+ " i TEXT insert TEXT before the current pattern space" -+ ); -+ stdout.WriteLine( -+ " l [N] list pattern space unambiguously" -+ ); -+ stdout.WriteLine( -+ " n, N read next record / append next record" -+ ); -+ stdout.WriteLine( -+ " p, P print pattern space / first pattern-space line" -+ ); -+ stdout.WriteLine( -+ " q, Q quit with / without automatic printing" -+ ); -+ stdout.WriteLine( -+ " r,R FILE append FILE / one successive line from FILE" -+ ); -+ stdout.WriteLine( -+ " sXreXreplacementXFLAGS substitute using delimiter X" -+ ); -+ stdout.WriteLine( -+ " FLAGS: N, e, g, p, i/I, m/M, w FILE" -+ ); -+ stdout.WriteLine( -+ " t,T LABEL branch after successful / unsuccessful substitution" -+ ); -+ stdout.WriteLine( -+ " w,W FILE write pattern space / first pattern-space line" -+ ); -+ stdout.WriteLine( -+ " yXsrcXdstX transliterate characters" -+ ); -+ stdout.WriteLine( -+ " :LABEL define a label; { ... } group commands; # comment" -+ ); -+ } -+ -+} -diff --git a/sed/src/SedProcesses.cs b/sed/src/SedProcesses.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..5c42df6a7d72401592acaa1b0412e4c660634827 ---- /dev/null -+++ b/sed/src/SedProcesses.cs -@@ -0,0 +1,280 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+ -+// Responsibility: shell execution and stream adaptation. -+public static partial class Command { -+ -+ /// Captures one shell process exit status and optional standard output. -+ internal sealed record ShellResult( -+ int ExitCode, -+ string StandardOutput -+ ); -+ -+ private sealed class TextWriterStream : Stream { -+ -+ private readonly Decoder myDecoder; -+ private readonly Encoding myEncoding; -+ private readonly TextWriter myWriter; -+ -+ public override bool CanRead { -+ get { -+ return false; -+ } -+ } -+ -+ public override bool CanSeek { -+ get { -+ return false; -+ } -+ } -+ -+ public override bool CanWrite { -+ get { -+ return true; -+ } -+ } -+ -+ public override long Length { -+ get { -+ throw new NotSupportedException(); -+ } -+ } -+ -+ public override long Position { -+ get { -+ throw new NotSupportedException(); -+ } -+ set { -+ throw new NotSupportedException(); -+ } -+ } -+ -+ public TextWriterStream( -+ TextWriter writer, -+ Encoding encoding -+ ) { -+ this.myWriter = writer ?? throw new ArgumentNullException( -+ nameof( writer ) -+ ); -+ this.myEncoding = encoding ?? throw new ArgumentNullException( -+ nameof( encoding ) -+ ); -+ this.myDecoder = encoding.GetDecoder(); -+ } -+ -+ public override void Flush() { -+ this.myWriter.Flush(); -+ } -+ -+ public override async Task FlushAsync( -+ CancellationToken cancellationToken -+ ) { -+ var characters = new char[ -+ this.myEncoding.GetMaxCharCount( -+ 0 -+ ) -+ ]; -+ this.myDecoder.Convert( -+ ReadOnlySpan.Empty, -+ characters.AsSpan(), -+ flush: true, -+ out _, -+ out var charactersUsed, -+ out _ -+ ); -+ if ( 0 < charactersUsed ) { -+ await this.myWriter.WriteAsync( -+ characters.AsMemory( -+ 0, -+ charactersUsed -+ ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ await this.myWriter.FlushAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ public override int Read( -+ byte[] buffer, -+ int offset, -+ int count -+ ) { -+ throw new NotSupportedException(); -+ } -+ -+ public override long Seek( -+ long offset, -+ SeekOrigin origin -+ ) { -+ throw new NotSupportedException(); -+ } -+ -+ public override void SetLength( -+ long value -+ ) { -+ throw new NotSupportedException(); -+ } -+ -+ public override void Write( -+ byte[] buffer, -+ int offset, -+ int count -+ ) { -+ var characters = new char[ -+ this.myEncoding.GetMaxCharCount( -+ count -+ ) -+ ]; -+ this.myDecoder.Convert( -+ buffer.AsSpan( -+ offset, -+ count -+ ), -+ characters.AsSpan(), -+ flush: false, -+ out _, -+ out var charactersUsed, -+ out _ -+ ); -+ this.myWriter.Write( -+ characters, -+ 0, -+ charactersUsed -+ ); -+ } -+ -+ public override async ValueTask WriteAsync( -+ ReadOnlyMemory buffer, -+ CancellationToken cancellationToken = default -+ ) { -+ if ( buffer.IsEmpty ) { -+ return; -+ } -+ var characters = new char[ -+ this.myEncoding.GetMaxCharCount( -+ buffer.Length -+ ) -+ ]; -+ this.myDecoder.Convert( -+ buffer.Span, -+ characters.AsSpan(), -+ flush: false, -+ out _, -+ out var charactersUsed, -+ out _ -+ ); -+ await this.myWriter.WriteAsync( -+ characters.AsMemory( -+ 0, -+ charactersUsed -+ ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ } -+ -+ private static async Task ExecuteShellAsync( -+ string command, -+ ExecutionEnvironment environment, -+ bool captureStandardOutput, -+ CancellationToken cancellationToken -+ ) { -+ if ( !captureStandardOutput ) { -+ await environment.Output.BeginOutputAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ return await environment.Shell.ExecuteAsync( -+ command, -+ new SedOutputTextWriter( environment.Output ), -+ environment.Error, -+ captureStandardOutput, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ -+ /// Executes Sed shell commands through the Shared process runner. -+ internal sealed class SystemSedShellCapability : ISedShellCapability { -+ -+ /// Gets the singleton host-backed shell capability. -+ public static SystemSedShellCapability Instance { get; } = new(); -+ -+ private SystemSedShellCapability() { -+ } -+ -+ /// -+ public async Task ExecuteAsync( -+ string command, -+ TextWriter output, -+ TextWriter error, -+ bool captureStandardOutput, -+ CancellationToken cancellationToken -+ ) { -+ ArgumentNullException.ThrowIfNull( command ); -+ ArgumentNullException.ThrowIfNull( output ); -+ ArgumentNullException.ThrowIfNull( error ); -+ cancellationToken.ThrowIfCancellationRequested(); -+ -+ await using var outputStream = captureStandardOutput -+ ? null -+ : new TextWriterStream( -+ output, -+ Encoding.UTF8 -+ ) -+ ; -+ await using var errorStream = new TextWriterStream( -+ error, -+ Encoding.UTF8 -+ ); -+ var options = new ProcessRunOptions( -+ OperatingSystem.IsWindows() -+ ? Environment.GetEnvironmentVariable( "COMSPEC" ) ?? "cmd.exe" -+ : "/bin/sh" -+ ) { -+ CaptureStandardOutput = captureStandardOutput, -+ OutputEncoding = Encoding.UTF8, -+ StandardError = errorStream, -+ StandardOutput = outputStream -+ }; -+ if ( OperatingSystem.IsWindows() ) { -+ options.Arguments.Add( "/d" ); -+ options.Arguments.Add( "/s" ); -+ options.Arguments.Add( "/c" ); -+ } else { -+ options.Arguments.Add( "-c" ); -+ } -+ options.Arguments.Add( command ); -+ var result = await ProcessRunner.RunAsync( -+ options, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( result.WasCanceled ) { -+ throw new OperationCanceledException( cancellationToken ); -+ } -+ if ( null != outputStream ) { -+ await outputStream.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ await errorStream.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ return new ShellResult( -+ result.ExitCode ?? ErrorExitCode, -+ result.StandardOutput ?? string.Empty -+ ); -+ } -+ -+ } -+ -+ -+} -diff --git a/sed/src/SedRecords.cs b/sed/src/SedRecords.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..7ee5401a718a58df3ced883062b5ed9175e5feb5 ---- /dev/null -+++ b/sed/src/SedRecords.cs -@@ -0,0 +1,775 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Buffers; -+using System.Collections.Generic; -+using System.IO; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.Records; -+using Icod.CommandFramework.Text; -+ -+// Responsibility: byte-preserving input, text mapping, and explicit record framing. -+public static partial class Command { -+ -+ private const int InvalidByteCharacterBase = 0xDC00; -+ -+ private enum SedRecordSeparatorKind { -+ LineFeed, -+ Null -+ } -+ -+ private sealed class SedInputSourceIdentity { -+ -+ public int Index { -+ get; -+ } -+ -+ public bool IsStandardInput { -+ get; -+ } -+ -+ public string Name { -+ get; -+ } -+ -+ public SedInputSourceIdentity( -+ int index, -+ string name, -+ bool isStandardInput -+ ) { -+ this.Index = index; -+ this.Name = name ?? throw new ArgumentNullException( nameof( name ) ); -+ this.IsStandardInput = isStandardInput; -+ } -+ -+ } -+ -+ private sealed class SedInputRecord { -+ -+ private readonly long[] myTextBoundaryByteOffsets; -+ -+ public long AggregateRecordNumber { -+ get; -+ } -+ -+ public ReadOnlyMemory Bytes { -+ get; -+ } -+ -+ public bool IsTerminated { -+ get; -+ } -+ -+ public SedRecordSeparatorKind SeparatorKind { -+ get; -+ } -+ -+ public SedInputSourceIdentity Source { -+ get; -+ } -+ -+ public long SourceRecordNumber { -+ get; -+ } -+ -+ public string Text { -+ get; -+ } -+ -+ public SedInputRecord( -+ ReadOnlyMemory bytes, -+ string text, -+ long[] textBoundaryByteOffsets, -+ SedInputSourceIdentity source, -+ long aggregateRecordNumber, -+ long sourceRecordNumber, -+ SedRecordSeparatorKind separatorKind, -+ bool isTerminated -+ ) { -+ this.Bytes = bytes; -+ this.Text = text ?? throw new ArgumentNullException( nameof( text ) ); -+ this.myTextBoundaryByteOffsets = textBoundaryByteOffsets -+ ?? throw new ArgumentNullException( nameof( textBoundaryByteOffsets ) ) -+ ; -+ this.Source = source ?? throw new ArgumentNullException( nameof( source ) ); -+ this.AggregateRecordNumber = aggregateRecordNumber; -+ this.SourceRecordNumber = sourceRecordNumber; -+ this.SeparatorKind = separatorKind; -+ this.IsTerminated = isTerminated; -+ } -+ -+ public bool TryGetByteOffset( -+ int textBoundary, -+ out long byteOffset -+ ) { -+ if ( -+ textBoundary < 0 -+ || this.myTextBoundaryByteOffsets.Length <= textBoundary -+ ) { -+ byteOffset = -1; -+ return false; -+ } -+ byteOffset = this.myTextBoundaryByteOffsets[ textBoundary ]; -+ return 0 <= byteOffset; -+ } -+ -+ } -+ -+ private sealed class SedTextCodec { -+ -+ private readonly bool myByteLocale; -+ -+ public ITextLocaleProvider Locale { -+ get; -+ } -+ -+ private SedTextCodec( -+ ITextLocaleProvider locale -+ ) { -+ this.Locale = locale ?? throw new ArgumentNullException( nameof( locale ) ); -+ this.myByteLocale = TextDecodingMode.Bytes == locale.DecodingMode; -+ } -+ -+ public static SedTextCodec CreateCurrent() { -+ return new SedTextCodec( -+ TextLocaleEnvironment.Resolve() -+ ); -+ } -+ -+ public SedInputRecord DecodeRecord( -+ ByteRecord record, -+ SedInputSourceIdentity source, -+ long aggregateRecordNumber, -+ long sourceRecordNumber, -+ SedRecordSeparatorKind separatorKind -+ ) { -+ ArgumentNullException.ThrowIfNull( record ); -+ var decoded = this.Decode( -+ record.Content.Span -+ ); -+ return new SedInputRecord( -+ record.Content, -+ decoded.Text, -+ decoded.BoundaryOffsets, -+ source, -+ aggregateRecordNumber, -+ sourceRecordNumber, -+ separatorKind, -+ record.IsTerminated -+ ); -+ } -+ -+ public byte[] Encode( -+ string value -+ ) { -+ ArgumentNullException.ThrowIfNull( value ); -+ var output = new ArrayBufferWriter( Math.Max( 1, value.Length ) ); -+ for ( var index = 0; index < value.Length; index++ ) { -+ var character = value[ index ]; -+ if ( -+ !this.myByteLocale -+ && InvalidByteCharacterBase <= character -+ && character <= InvalidByteCharacterBase + byte.MaxValue -+ ) { -+ output.GetSpan( 1 )[ 0 ] = (byte)( character - InvalidByteCharacterBase ); -+ output.Advance( 1 ); -+ continue; -+ } -+ -+ Rune rune; -+ if ( -+ char.IsHighSurrogate( character ) -+ && index + 1 < value.Length -+ && Rune.TryCreate( character, value[ index + 1 ], out rune ) -+ ) { -+ index++; -+ } else if ( char.IsSurrogate( character ) ) { -+ rune = Rune.ReplacementChar; -+ } else { -+ rune = new Rune( character ); -+ } -+ -+ if ( this.myByteLocale && rune.Value <= byte.MaxValue ) { -+ output.GetSpan( 1 )[ 0 ] = (byte)rune.Value; -+ output.Advance( 1 ); -+ continue; -+ } -+ var destination = output.GetSpan( 4 ); -+ var count = rune.EncodeToUtf8( destination ); -+ output.Advance( count ); -+ } -+ return output.WrittenSpan.ToArray(); -+ } -+ -+ private (string Text, long[] BoundaryOffsets) Decode( -+ ReadOnlySpan bytes -+ ) { -+ if ( this.myByteLocale ) { -+ var characters = new char[ bytes.Length ]; -+ var offsets = new long[ bytes.Length + 1 ]; -+ for ( var index = 0; index < bytes.Length; index++ ) { -+ characters[ index ] = (char)bytes[ index ]; -+ offsets[ index ] = index; -+ } -+ offsets[ bytes.Length ] = bytes.Length; -+ return ( -+ new string( characters ), -+ offsets -+ ); -+ } -+ -+ var text = new StringBuilder( bytes.Length ); -+ var boundaryOffsets = new List( bytes.Length + 1 ) { 0 }; -+ var byteIndex = 0; -+ while ( byteIndex < bytes.Length ) { -+ var status = Rune.DecodeFromUtf8( -+ bytes.Slice( byteIndex ), -+ out var rune, -+ out var consumed -+ ); -+ if ( -+ OperationStatus.Done != status -+ || consumed <= 0 -+ ) { -+ text.Append( -+ (char)( InvalidByteCharacterBase + bytes[ byteIndex ] ) -+ ); -+ byteIndex++; -+ boundaryOffsets.Add( byteIndex ); -+ continue; -+ } -+ -+ var runeText = rune.ToString(); -+ text.Append( runeText ); -+ for ( var characterIndex = 1; characterIndex < runeText.Length; characterIndex++ ) { -+ boundaryOffsets.Add( -1 ); -+ } -+ byteIndex += consumed; -+ boundaryOffsets.Add( byteIndex ); -+ } -+ return ( -+ text.ToString(), -+ boundaryOffsets.ToArray() -+ ); -+ } -+ -+ } -+ -+ private sealed class SourceSpec { -+ -+ public string Path { -+ get; -+ } -+ -+ public SourceSpec( -+ string path -+ ) { -+ this.Path = path; -+ } -+ -+ } -+ -+ private sealed class AsyncRecordReader : IDisposable { -+ -+ private readonly SedTextCodec myCodec; -+ private readonly bool myOwnsStream; -+ private readonly ByteRecordReader myReader; -+ private readonly SedRecordSeparatorKind mySeparatorKind; -+ private readonly SedInputSourceIdentity mySource; -+ private readonly Stream myStream; -+ private long mySourceRecordNumber; -+ -+ public AsyncRecordReader( -+ Stream stream, -+ bool nullData, -+ bool ownsStream, -+ SedTextCodec codec, -+ SedInputSourceIdentity source -+ ) { -+ this.myStream = stream ?? throw new ArgumentNullException( nameof( stream ) ); -+ this.myOwnsStream = ownsStream; -+ this.myCodec = codec ?? throw new ArgumentNullException( nameof( codec ) ); -+ this.mySource = source ?? throw new ArgumentNullException( nameof( source ) ); -+ this.mySeparatorKind = nullData -+ ? SedRecordSeparatorKind.Null -+ : SedRecordSeparatorKind.LineFeed -+ ; -+ this.myReader = new ByteRecordReader( -+ stream, -+ nullData -+ ? RecordSeparator.Null -+ : RecordSeparator.LineFeed, -+ bufferSize: 8192 -+ ); -+ } -+ -+ public async Task ReadAsync( -+ long aggregateRecordNumber, -+ CancellationToken cancellationToken -+ ) { -+ var record = await this.myReader.ReadAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( null == record ) { -+ return null; -+ } -+ this.mySourceRecordNumber++; -+ return this.myCodec.DecodeRecord( -+ record, -+ this.mySource, -+ aggregateRecordNumber, -+ this.mySourceRecordNumber, -+ this.mySeparatorKind -+ ); -+ } -+ -+ public void Dispose() { -+ this.myReader.Dispose(); -+ if ( this.myOwnsStream ) { -+ this.myStream.Dispose(); -+ } -+ } -+ -+ } -+ -+ private sealed class InputSequence : IDisposable { -+ -+ private long myAggregateRecordNumber; -+ private AsyncRecordReader? myCurrentReader; -+ private bool myInitialized; -+ private SedInputRecord? myLookahead; -+ private bool myLookaheadAvailable; -+ private readonly bool myNullData; -+ private int mySourceIndex = -1; -+ private readonly IReadOnlyList mySources; -+ private readonly Stream myStandardInput; -+ private readonly SedTextCodec myTextCodec; -+ -+ public SedInputRecord Current { -+ get; -+ private set; -+ } = null!; -+ -+ public bool IsLast { -+ get; -+ private set; -+ } -+ -+ public long LineNumber => this.Current.AggregateRecordNumber; -+ -+ public InputSequence( -+ IReadOnlyList sources, -+ Stream standardInput, -+ bool nullData, -+ SedTextCodec textCodec -+ ) { -+ this.mySources = sources ?? throw new ArgumentNullException( nameof( sources ) ); -+ this.myStandardInput = standardInput ?? throw new ArgumentNullException( nameof( standardInput ) ); -+ this.myNullData = nullData; -+ this.myTextCodec = textCodec ?? throw new ArgumentNullException( nameof( textCodec ) ); -+ } -+ -+ public async Task MoveNextAsync( -+ CancellationToken cancellationToken -+ ) { -+ if ( !this.myInitialized ) { -+ this.myInitialized = true; -+ this.myLookahead = await this.ReadRawAsync( cancellationToken ).ConfigureAwait( false ); -+ this.myLookaheadAvailable = null != this.myLookahead; -+ } -+ if ( !this.myLookaheadAvailable ) { -+ return false; -+ } -+ this.Current = this.myLookahead!; -+ this.myLookahead = await this.ReadRawAsync( cancellationToken ).ConfigureAwait( false ); -+ this.myLookaheadAvailable = null != this.myLookahead; -+ this.IsLast = !this.myLookaheadAvailable; -+ return true; -+ } -+ -+ private async Task ReadRawAsync( -+ CancellationToken cancellationToken -+ ) { -+ while ( true ) { -+ if ( null == this.myCurrentReader && !this.OpenNextSource() ) { -+ return null; -+ } -+ var value = await this.myCurrentReader!.ReadAsync( -+ this.myAggregateRecordNumber + 1, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( null != value ) { -+ this.myAggregateRecordNumber++; -+ return value; -+ } -+ this.CloseCurrentReader(); -+ } -+ } -+ -+ private bool OpenNextSource() { -+ this.mySourceIndex++; -+ if ( this.mySources.Count <= this.mySourceIndex ) { -+ return false; -+ } -+ var source = this.mySources[ this.mySourceIndex ]; -+ var isStandardInput = "-" == source.Path; -+ var identity = new SedInputSourceIdentity( -+ this.mySourceIndex, -+ source.Path, -+ isStandardInput -+ ); -+ var stream = isStandardInput -+ ? this.myStandardInput -+ : new FileStream( -+ source.Path, -+ FileMode.Open, -+ FileAccess.Read, -+ FileShare.Read, -+ 8192, -+ useAsync: true -+ ) -+ ; -+ this.myCurrentReader = new AsyncRecordReader( -+ stream, -+ this.myNullData, -+ ownsStream: !isStandardInput, -+ this.myTextCodec, -+ identity -+ ); -+ return true; -+ } -+ -+ private void CloseCurrentReader() { -+ this.myCurrentReader?.Dispose(); -+ this.myCurrentReader = null; -+ } -+ -+ public void Dispose() { -+ this.CloseCurrentReader(); -+ } -+ -+ } -+ -+ private sealed class SedOutputWriter { -+ -+ private readonly SedTextCodec myCodec; -+ private bool myPendingRecordSeparator; -+ private readonly DelimitedByteRecordWriter myWriter; -+ -+ public bool AutoFlush { -+ get; -+ set; -+ } -+ -+ public SedOutputWriter( -+ Stream stream, -+ SedTextCodec codec, -+ bool nullData -+ ) { -+ this.myCodec = codec ?? throw new ArgumentNullException( nameof( codec ) ); -+ this.myWriter = new DelimitedByteRecordWriter( -+ stream ?? throw new ArgumentNullException( nameof( stream ) ), -+ nullData ? RecordSeparator.Null : RecordSeparator.LineFeed -+ ); -+ } -+ -+ public async Task BeginOutputAsync( -+ CancellationToken cancellationToken -+ ) { -+ if ( this.myPendingRecordSeparator ) { -+ await this.myWriter.WriteSeparatorAsync( cancellationToken ).ConfigureAwait( false ); -+ this.myPendingRecordSeparator = false; -+ if ( this.AutoFlush ) { -+ await this.myWriter.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ } -+ } -+ -+ public async Task WriteRecordAsync( -+ string value, -+ bool terminate, -+ CancellationToken cancellationToken -+ ) { -+ await this.BeginOutputAsync( cancellationToken ).ConfigureAwait( false ); -+ var bytes = this.myCodec.Encode( value ); -+ await this.myWriter.WriteRecordAsync( -+ bytes, -+ terminate, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ this.myPendingRecordSeparator = !terminate; -+ if ( this.AutoFlush ) { -+ await this.myWriter.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ } -+ -+ public async Task WriteRawTextAsync( -+ string value, -+ CancellationToken cancellationToken -+ ) { -+ await this.BeginOutputAsync( cancellationToken ).ConfigureAwait( false ); -+ var bytes = this.myCodec.Encode( value ); -+ await this.myWriter.WriteContentAsync( -+ bytes, -+ cancellationToken -+ ).ConfigureAwait( false ); -+ if ( this.AutoFlush ) { -+ await this.myWriter.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ } -+ -+ public Task FlushAsync( -+ CancellationToken cancellationToken -+ ) { -+ return this.myWriter.FlushAsync( cancellationToken ).AsTask(); -+ } -+ -+ } -+ -+ private sealed class SedOutputTextWriter : TextWriter { -+ -+ private readonly SedOutputWriter myWriter; -+ -+ public override Encoding Encoding => Encoding.UTF8; -+ -+ public SedOutputTextWriter( -+ SedOutputWriter writer -+ ) { -+ this.myWriter = writer ?? throw new ArgumentNullException( nameof( writer ) ); -+ } -+ -+ public override void Write( -+ char value -+ ) { -+ this.Write( value.ToString() ); -+ } -+ -+ public override void Write( -+ char[] buffer, -+ int index, -+ int count -+ ) { -+ ArgumentNullException.ThrowIfNull( buffer ); -+ this.Write( new string( buffer, index, count ) ); -+ } -+ -+ public override void Write( -+ string? value -+ ) { -+ if ( null != value ) { -+ this.myWriter.WriteRawTextAsync( value, CancellationToken.None ).GetAwaiter().GetResult(); -+ } -+ } -+ -+ public override Task WriteAsync( -+ string? value -+ ) { -+ return null == value -+ ? Task.CompletedTask -+ : this.myWriter.WriteRawTextAsync( value, CancellationToken.None ) -+ ; -+ } -+ -+ public override Task WriteAsync( -+ ReadOnlyMemory buffer, -+ CancellationToken cancellationToken = default -+ ) { -+ return this.myWriter.WriteRawTextAsync( buffer.ToString(), cancellationToken ); -+ } -+ -+ public override Task FlushAsync() { -+ return this.myWriter.FlushAsync( CancellationToken.None ); -+ } -+ -+ public override Task FlushAsync( -+ CancellationToken cancellationToken -+ ) { -+ return this.myWriter.FlushAsync( cancellationToken ); -+ } -+ -+ } -+ -+ private sealed class TextReaderInputStream : Stream { -+ -+ private readonly byte[] myByteBuffer = new byte[ 16384 ]; -+ private int myByteCount; -+ private int myByteOffset; -+ private readonly char[] myCharacterBuffer = new char[ 4096 ]; -+ private readonly Encoder myEncoder = new UTF8Encoding( -+ encoderShouldEmitUTF8Identifier: false -+ ).GetEncoder(); -+ private bool myEndOfInput; -+ private readonly TextReader myReader; -+ -+ public TextReaderInputStream( -+ TextReader reader -+ ) { -+ this.myReader = reader ?? throw new ArgumentNullException( nameof( reader ) ); -+ } -+ -+ public override bool CanRead => true; -+ public override bool CanSeek => false; -+ public override bool CanWrite => false; -+ public override long Length => throw new NotSupportedException(); -+ public override long Position { -+ get => throw new NotSupportedException(); -+ set => throw new NotSupportedException(); -+ } -+ public override void Flush() { -+ } -+ public override int Read( byte[] buffer, int offset, int count ) => this.ReadAsync( -+ buffer.AsMemory( offset, count ), -+ CancellationToken.None -+ ).AsTask().GetAwaiter().GetResult(); -+ public override async ValueTask ReadAsync( -+ Memory buffer, -+ CancellationToken cancellationToken = default -+ ) { -+ if ( buffer.IsEmpty ) { -+ return 0; -+ } -+ while ( this.myByteOffset >= this.myByteCount ) { -+ if ( this.myEndOfInput ) { -+ return 0; -+ } -+ var characterCount = await this.myReader.ReadAsync( -+ this.myCharacterBuffer.AsMemory(), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ this.myEndOfInput = 0 == characterCount; -+ this.myEncoder.Convert( -+ this.myCharacterBuffer.AsSpan( 0, characterCount ), -+ this.myByteBuffer, -+ flush: this.myEndOfInput, -+ out _, -+ out this.myByteCount, -+ out _ -+ ); -+ this.myByteOffset = 0; -+ } -+ var count = Math.Min( buffer.Length, this.myByteCount - this.myByteOffset ); -+ this.myByteBuffer.AsMemory( this.myByteOffset, count ).CopyTo( buffer ); -+ this.myByteOffset += count; -+ return count; -+ } -+ public override long Seek( long offset, SeekOrigin origin ) => throw new NotSupportedException(); -+ public override void SetLength( long value ) => throw new NotSupportedException(); -+ public override void Write( byte[] buffer, int offset, int count ) => throw new NotSupportedException(); -+ -+ } -+ -+ private sealed class TextWriterOutputStream : Stream { -+ -+ private readonly Decoder myDecoder = new UTF8Encoding( -+ encoderShouldEmitUTF8Identifier: false -+ ).GetDecoder(); -+ private readonly TextWriter myWriter; -+ -+ public TextWriterOutputStream( -+ TextWriter writer -+ ) { -+ this.myWriter = writer ?? throw new ArgumentNullException( nameof( writer ) ); -+ } -+ -+ public override bool CanRead => false; -+ public override bool CanSeek => false; -+ public override bool CanWrite => true; -+ public override long Length => throw new NotSupportedException(); -+ public override long Position { -+ get => throw new NotSupportedException(); -+ set => throw new NotSupportedException(); -+ } -+ public override void Flush() { -+ this.FlushDecoder( flush: true ); -+ this.myWriter.Flush(); -+ } -+ public override async Task FlushAsync( CancellationToken cancellationToken ) { -+ await this.FlushDecoderAsync( flush: true, cancellationToken ).ConfigureAwait( false ); -+ await this.myWriter.FlushAsync( cancellationToken ).ConfigureAwait( false ); -+ } -+ public override int Read( byte[] buffer, int offset, int count ) => throw new NotSupportedException(); -+ public override long Seek( long offset, SeekOrigin origin ) => throw new NotSupportedException(); -+ public override void SetLength( long value ) => throw new NotSupportedException(); -+ public override void Write( byte[] buffer, int offset, int count ) { -+ ArgumentNullException.ThrowIfNull( buffer ); -+ this.WriteDecoded( buffer.AsSpan( offset, count ), flush: false ); -+ } -+ public override async ValueTask WriteAsync( -+ ReadOnlyMemory buffer, -+ CancellationToken cancellationToken = default -+ ) { -+ await this.WriteDecodedAsync( buffer, flush: false, cancellationToken ).ConfigureAwait( false ); -+ } -+ -+ private void FlushDecoder( -+ bool flush -+ ) { -+ this.WriteDecoded( ReadOnlySpan.Empty, flush ); -+ } -+ -+ private Task FlushDecoderAsync( -+ bool flush, -+ CancellationToken cancellationToken -+ ) { -+ return this.WriteDecodedAsync( ReadOnlyMemory.Empty, flush, cancellationToken ).AsTask(); -+ } -+ -+ private void WriteDecoded( -+ ReadOnlySpan bytes, -+ bool flush -+ ) { -+ var characters = new char[ Math.Max( 1, Encoding.UTF8.GetMaxCharCount( bytes.Length ) ) ]; -+ this.myDecoder.Convert( -+ bytes, -+ characters, -+ flush, -+ out _, -+ out var charactersUsed, -+ out _ -+ ); -+ if ( 0 < charactersUsed ) { -+ this.myWriter.Write( characters, 0, charactersUsed ); -+ } -+ } -+ -+ private async ValueTask WriteDecodedAsync( -+ ReadOnlyMemory bytes, -+ bool flush, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ var characters = new char[ Math.Max( 1, Encoding.UTF8.GetMaxCharCount( bytes.Length ) ) ]; -+ this.myDecoder.Convert( -+ bytes.Span, -+ characters, -+ flush, -+ out _, -+ out var charactersUsed, -+ out _ -+ ); -+ if ( 0 < charactersUsed ) { -+ await this.myWriter.WriteAsync( -+ characters.AsMemory( 0, charactersUsed ), -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ } -+ -+ } -+ -+ private static Task WriteRecordAsync( -+ SedOutputWriter writer, -+ string value, -+ bool terminate, -+ CancellationToken cancellationToken -+ ) { -+ return writer.WriteRecordAsync( -+ value, -+ terminate, -+ cancellationToken -+ ); -+ } -+ -+} -diff --git a/sed/src/SedRegularExpressions.cs b/sed/src/SedRegularExpressions.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..316f916e87a334dbbe0356f7a4fa9e90243483d0 ---- /dev/null -+++ b/sed/src/SedRegularExpressions.cs -@@ -0,0 +1,600 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.Threading; -+using Icod.CommandFramework.RegularExpressions; -+using Icod.CommandFramework.Text; -+ -+// Responsibility: Sed-specific regular-expression policy over the Shared GNU provider. -+public static partial class Command { -+ -+ private enum SedRegularExpressionContext { -+ Address, -+ Substitution -+ } -+ -+ private sealed class SedCompiledRegularExpression { -+ -+ private readonly ICompiledRegularExpression myExpression; -+ -+ public SedRegularExpressionContext Context { -+ get; -+ } -+ -+ public string Pattern { -+ get { -+ return this.myExpression.Pattern; -+ } -+ } -+ -+ public SedCompiledRegularExpression( -+ ICompiledRegularExpression expression, -+ SedRegularExpressionContext context -+ ) { -+ this.myExpression = expression ?? throw new ArgumentNullException( -+ nameof( expression ) -+ ); -+ this.Context = context; -+ } -+ -+ public bool IsMatch( -+ string input, -+ CancellationToken cancellationToken -+ ) { -+ return null != this.FindMatch( -+ input, -+ 0, -+ cancellationToken -+ ); -+ } -+ -+ public IReadOnlyList FindMatches( -+ string input, -+ CancellationToken cancellationToken -+ ) { -+ ArgumentNullException.ThrowIfNull( input ); -+ var output = new List(); -+ var searchStart = 0; -+ int? precedingNonEmptyEnd = null; -+ -+ while ( searchStart <= input.Length ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ var match = this.FindMatch( -+ input, -+ searchStart, -+ cancellationToken -+ ); -+ if ( null == match ) { -+ break; -+ } -+ -+ if ( -+ 0 == match.Length -+ && precedingNonEmptyEnd.HasValue -+ && precedingNonEmptyEnd.Value == match.Index -+ ) { -+ if ( match.Index >= input.Length ) { -+ break; -+ } -+ searchStart = AdvanceStringIndex( -+ input, -+ match.Index -+ ); -+ continue; -+ } -+ -+ output.Add( -+ match -+ ); -+ if ( 0 < match.Length ) { -+ searchStart = match.Index + match.Length; -+ precedingNonEmptyEnd = searchStart; -+ } else { -+ precedingNonEmptyEnd = null; -+ if ( match.Index >= input.Length ) { -+ break; -+ } -+ searchStart = AdvanceStringIndex( -+ input, -+ match.Index -+ ); -+ } -+ } -+ -+ return output; -+ } -+ -+ private static int AdvanceStringIndex( -+ string input, -+ int index -+ ) { -+ if ( -+ index + 1 < input.Length -+ && char.IsHighSurrogate( input[ index ] ) -+ && char.IsLowSurrogate( input[ index + 1 ] ) -+ ) { -+ return index + 2; -+ } -+ return index + 1; -+ } -+ -+ private RegularExpressionMatch? FindMatch( -+ string input, -+ int startIndex, -+ CancellationToken cancellationToken -+ ) { -+ var result = this.myExpression.Match( -+ input, -+ new RegularExpressionMatchOptions { -+ StartIndex = startIndex -+ }, -+ cancellationToken -+ ); -+ if ( !result.IsSuccess ) { -+ var diagnostic = result.Diagnostic -+ ?? throw new InvalidOperationException( -+ "regular-expression matching failed without a diagnostic" -+ ) -+ ; -+ throw new InvalidOperationException( -+ $"regular expression match failed: {diagnostic.Message}" -+ ); -+ } -+ return result.Match; -+ } -+ -+ } -+ -+ private sealed class SedRegularExpressionCompiler { -+ -+ private readonly CancellationToken myCancellationToken; -+ private readonly IRegularExpressionProvider myProvider; -+ private readonly bool myNullData; -+ private readonly bool myPosix; -+ private readonly bool myExtendedRegularExpressions; -+ private SedCompiledRegularExpression? myLastExpression; -+ -+ public SedRegularExpressionCompiler( -+ bool extendedRegularExpressions, -+ bool posix, -+ bool nullData, -+ ITextLocaleProvider textLocale, -+ CancellationToken cancellationToken -+ ) { -+ this.myExtendedRegularExpressions = extendedRegularExpressions; -+ this.myPosix = posix; -+ this.myNullData = nullData; -+ this.myCancellationToken = cancellationToken; -+ var characterClasses = CreateSedCharacterClassProvider( textLocale ); -+ this.myProvider = extendedRegularExpressions -+ ? new GnuExtendedRegularExpressionProvider( -+ characterClasses -+ ) -+ : new GnuBasicRegularExpressionProvider( -+ characterClasses -+ ) -+ ; -+ } -+ -+ public SedCompiledRegularExpression Compile( -+ string pattern, -+ SedRegularExpressionContext context, -+ bool ignoreCase = false, -+ bool multiline = false -+ ) { -+ ArgumentNullException.ThrowIfNull( pattern ); -+ this.myCancellationToken.ThrowIfCancellationRequested(); -+ -+ if ( 0 == pattern.Length ) { -+ if ( ignoreCase || multiline ) { -+ throw new ScriptParseException( -+ "cannot specify modifiers on an empty regular expression" -+ ); -+ } -+ return this.myLastExpression -+ ?? throw new ScriptParseException( -+ "no previous regular expression" -+ ) -+ ; -+ } -+ -+ var sedPattern = ExpandSedRegularExpressionEscapes( -+ pattern, -+ this.myPosix -+ ); -+ var effectivePattern = this.myPosix -+ ? NormalizePosixRegularExpression( -+ sedPattern, -+ this.myExtendedRegularExpressions -+ ) -+ : sedPattern -+ ; -+ var result = this.myProvider.Compile( -+ effectivePattern, -+ new RegularExpressionOptions { -+ Syntax = this.myExtendedRegularExpressions -+ ? GnuRegularExpressionSyntax.Extended -+ : GnuRegularExpressionSyntax.Basic, -+ IgnoreCase = ignoreCase, -+ NewLineSensitive = multiline, -+ LineSeparator = new System.Text.Rune( this.myNullData ? '\0' : '\n' ), -+ DotMatchesNull = this.myNullData -+ }, -+ this.myCancellationToken -+ ); -+ if ( !result.IsSuccess ) { -+ var diagnostic = result.Diagnostic -+ ?? throw new ScriptParseException( -+ "invalid regular expression" -+ ) -+ ; -+ var contextText = SedRegularExpressionContext.Address == context -+ ? "address" -+ : "substitution" -+ ; -+ throw new ScriptParseException( -+ $"invalid regular expression in {contextText}: {diagnostic.Message}" -+ ); -+ } -+ -+ var output = new SedCompiledRegularExpression( -+ result.Expression -+ ?? throw new ScriptParseException( -+ "regular-expression compilation succeeded without an expression" -+ ), -+ context -+ ); -+ this.myLastExpression = output; -+ return output; -+ } -+ -+ private static IRegularExpressionCharacterClassProvider CreateSedCharacterClassProvider( -+ ITextLocaleProvider textLocale -+ ) { -+ ArgumentNullException.ThrowIfNull( textLocale ); -+ return TextDecodingMode.Bytes == textLocale.DecodingMode -+ ? PosixCLocaleRegularExpressionCharacterClassProvider.Instance -+ : new UnicodeRegularExpressionCharacterClassProvider( -+ CultureInfo.CurrentCulture -+ ) -+ ; -+ } -+ -+ private static string ExpandSedRegularExpressionEscapes( -+ string pattern, -+ bool posix -+ ) { -+ var rawBracketPositions = FindRawBracketPositions( -+ pattern -+ ); -+ var output = new System.Text.StringBuilder( -+ pattern.Length -+ ); -+ for ( var index = 0; index < pattern.Length; index++ ) { -+ var character = pattern[ index ]; -+ if ( -+ '\\' != character -+ || index + 1 >= pattern.Length -+ ) { -+ output.Append( character ); -+ continue; -+ } -+ -+ if ( -+ posix -+ && rawBracketPositions[ index ] -+ ) { -+ output.Append( character ); -+ continue; -+ } -+ -+ var escaped = pattern[ ++index ]; -+ switch ( escaped ) { -+ case 'a': -+ output.Append( '\a' ); -+ break; -+ case 'f': -+ output.Append( '\f' ); -+ break; -+ case 'n': -+ output.Append( '\n' ); -+ break; -+ case 'r': -+ output.Append( '\r' ); -+ break; -+ case 't': -+ output.Append( '\t' ); -+ break; -+ case 'v': -+ output.Append( '\v' ); -+ break; -+ case 'c': -+ if ( index + 1 >= pattern.Length ) { -+ throw new ScriptParseException( -+ "unterminated control-character escape in regular expression" -+ ); -+ } -+ var control = pattern[ ++index ]; -+ if ( control is >= 'a' and <= 'z' ) { -+ control = char.ToUpperInvariant( control ); -+ } -+ output.Append( -+ (char)( control ^ 0x40 ) -+ ); -+ break; -+ case 'd': -+ AppendNumericEscape( -+ pattern, -+ ref index, -+ 10, -+ 3, -+ escaped, -+ output -+ ); -+ break; -+ case 'o': -+ AppendNumericEscape( -+ pattern, -+ ref index, -+ 8, -+ 3, -+ escaped, -+ output -+ ); -+ break; -+ case 'x': -+ AppendNumericEscape( -+ pattern, -+ ref index, -+ 16, -+ 2, -+ escaped, -+ output -+ ); -+ break; -+ default: -+ output.Append( '\\' ); -+ output.Append( escaped ); -+ break; -+ } -+ } -+ return output.ToString(); -+ } -+ -+ private static void AppendNumericEscape( -+ string pattern, -+ ref int index, -+ int numberBase, -+ int maximumDigits, -+ char escape, -+ System.Text.StringBuilder output -+ ) { -+ var value = 0; -+ var digits = 0; -+ while ( -+ digits < maximumDigits -+ && index + 1 < pattern.Length -+ ) { -+ var digit = GetDigitValue( -+ pattern[ index + 1 ] -+ ); -+ if ( digit < 0 || digit >= numberBase ) { -+ break; -+ } -+ value = checked( value * numberBase + digit ); -+ index++; -+ digits++; -+ } -+ if ( 0 == digits ) { -+ output.Append( escape ); -+ return; -+ } -+ output.Append( -+ (char)( value & 0xff ) -+ ); -+ } -+ -+ private static int GetDigitValue( -+ char character -+ ) { -+ if ( character is >= '0' and <= '9' ) { -+ return character - '0'; -+ } -+ if ( character is >= 'a' and <= 'f' ) { -+ return character - 'a' + 10; -+ } -+ if ( character is >= 'A' and <= 'F' ) { -+ return character - 'A' + 10; -+ } -+ return -1; -+ } -+ -+ private static bool[] FindRawBracketPositions( -+ string pattern -+ ) { -+ var output = new bool[ pattern.Length ]; -+ var inBracketExpression = false; -+ var bracketPosition = 0; -+ var bracketAllowsLeadingCaret = false; -+ for ( var index = 0; index < pattern.Length; index++ ) { -+ output[ index ] = inBracketExpression; -+ var character = pattern[ index ]; -+ if ( !inBracketExpression ) { -+ if ( -+ '\\' == character -+ && index + 1 < pattern.Length -+ ) { -+ output[ ++index ] = false; -+ continue; -+ } -+ if ( '[' == character ) { -+ inBracketExpression = true; -+ bracketPosition = 0; -+ bracketAllowsLeadingCaret = true; -+ } -+ continue; -+ } -+ -+ if ( -+ bracketAllowsLeadingCaret -+ && '^' == character -+ ) { -+ bracketAllowsLeadingCaret = false; -+ continue; -+ } -+ bracketAllowsLeadingCaret = false; -+ if ( -+ '[' == character -+ && index + 1 < pattern.Length -+ && pattern[ index + 1 ] is ':' or '.' or '=' -+ ) { -+ var marker = pattern[ index + 1 ]; -+ output[ index + 1 ] = true; -+ index += 2; -+ while ( index < pattern.Length ) { -+ output[ index ] = true; -+ if ( -+ marker == pattern[ index ] -+ && index + 1 < pattern.Length -+ && ']' == pattern[ index + 1 ] -+ ) { -+ output[ ++index ] = true; -+ break; -+ } -+ index++; -+ } -+ bracketPosition++; -+ continue; -+ } -+ if ( -+ ']' == character -+ && 0 < bracketPosition -+ ) { -+ inBracketExpression = false; -+ } -+ bracketPosition++; -+ } -+ return output; -+ } -+ -+ private static string NormalizePosixRegularExpression( -+ string pattern, -+ bool extendedRegularExpressions -+ ) { -+ var output = new System.Text.StringBuilder( -+ pattern.Length -+ ); -+ var inBracketExpression = false; -+ var bracketPosition = 0; -+ var bracketAllowsLeadingCaret = false; -+ for ( var index = 0; index < pattern.Length; index++ ) { -+ var character = pattern[ index ]; -+ if ( inBracketExpression ) { -+ if ( -+ bracketAllowsLeadingCaret -+ && '^' == character -+ ) { -+ output.Append( character ); -+ bracketAllowsLeadingCaret = false; -+ continue; -+ } -+ -+ bracketAllowsLeadingCaret = false; -+ if ( -+ '[' == character -+ && index + 1 < pattern.Length -+ && pattern[ index + 1 ] is ':' or '.' or '=' -+ ) { -+ var marker = pattern[ index + 1 ]; -+ output.Append( character ); -+ output.Append( marker ); -+ index += 2; -+ while ( index < pattern.Length ) { -+ output.Append( pattern[ index ] ); -+ if ( -+ marker == pattern[ index ] -+ && index + 1 < pattern.Length -+ && ']' == pattern[ index + 1 ] -+ ) { -+ output.Append( ']' ); -+ index++; -+ break; -+ } -+ index++; -+ } -+ bracketPosition++; -+ continue; -+ } -+ -+ output.Append( character ); -+ if ( -+ '\\' == character -+ && index + 1 < pattern.Length -+ ) { -+ output.Append( pattern[ ++index ] ); -+ bracketPosition++; -+ continue; -+ } -+ if ( -+ ']' == character -+ && 0 < bracketPosition -+ ) { -+ inBracketExpression = false; -+ } -+ bracketPosition++; -+ continue; -+ } -+ -+ if ( '[' == character ) { -+ inBracketExpression = true; -+ bracketPosition = 0; -+ bracketAllowsLeadingCaret = true; -+ output.Append( character ); -+ continue; -+ } -+ -+ if ( -+ '\\' == character -+ && index + 1 < pattern.Length -+ ) { -+ var escaped = pattern[ index + 1 ]; -+ if ( '\\' == escaped ) { -+ output.Append( character ); -+ output.Append( escaped ); -+ index++; -+ continue; -+ } -+ if ( -+ IsGnuAssertionEscape( escaped ) -+ || ( -+ !extendedRegularExpressions -+ && escaped is '+' or '?' or '|' -+ ) -+ ) { -+ output.Append( escaped ); -+ index++; -+ continue; -+ } -+ output.Append( character ); -+ output.Append( escaped ); -+ index++; -+ continue; -+ } -+ -+ output.Append( character ); -+ } -+ return output.ToString(); -+ } -+ -+ private static bool IsGnuAssertionEscape( -+ char character -+ ) { -+ return character is 'w' or 'W' or 's' or 'S' -+ or '<' or '>' or 'b' or 'B' or '`' or '\''; -+ } -+ -+ } -+ -+} -diff --git a/sed/src/SedScriptSources.cs b/sed/src/SedScriptSources.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..489bc9b8115b264738504f1c10a357c598a19ce8 ---- /dev/null -+++ b/sed/src/SedScriptSources.cs -@@ -0,0 +1,164 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Text; -+ -+// Responsibility: ordered script-source identity and aggregate location mapping. -+public static partial class Command { -+ -+ /// Identifies how one Sed script source entered the invocation. -+ internal enum SedScriptSourceKind { -+ /// A command-line -e expression. -+ Expression, -+ /// A command-line -f file. -+ File, -+ /// The implicit first operand used as the script. -+ ImplicitOperand -+ } -+ -+ /// Preserves one independently named Sed script source. -+ internal sealed class SedScriptSource { -+ -+ /// Gets the source kind. -+ public SedScriptSourceKind Kind { -+ get; -+ } -+ -+ /// Gets the stable display name used in diagnostics. -+ public string Name { -+ get; -+ } -+ -+ /// Gets the zero-based source order. -+ public int Order { -+ get; -+ } -+ -+ /// Gets the source text exactly as supplied or read. -+ public string Text { -+ get; -+ } -+ -+ /// Initializes one script source. -+ public SedScriptSource( -+ SedScriptSourceKind kind, -+ string name, -+ string text, -+ int order -+ ) { -+ if ( order < 0 ) { -+ throw new ArgumentOutOfRangeException( nameof( order ) ); -+ } -+ this.Kind = kind; -+ this.Name = name ?? throw new ArgumentNullException( nameof( name ) ); -+ this.Text = text ?? throw new ArgumentNullException( nameof( text ) ); -+ this.Order = order; -+ } -+ -+ } -+ -+ /// Identifies a one-based line and column inside a named script source. -+ internal readonly record struct SedScriptLocation( -+ string SourceName, -+ int Line, -+ int Column -+ ); -+ -+ /// Provides one LF-delimited parser view while retaining source boundaries. -+ internal sealed class SedScriptDocument { -+ -+ private sealed record SourceSpan( -+ SedScriptSource Source, -+ int Start, -+ int Length -+ ); -+ -+ private readonly IReadOnlyList mySpans; -+ -+ /// Gets the ordered original sources. -+ public IReadOnlyList Sources { -+ get; -+ } -+ -+ /// Gets the aggregate parser text, separated only with LF. -+ public string Text { -+ get; -+ } -+ -+ private SedScriptDocument( -+ IReadOnlyList sources, -+ string text, -+ IReadOnlyList spans -+ ) { -+ this.Sources = sources; -+ this.Text = text; -+ this.mySpans = spans; -+ } -+ -+ /// Creates an aggregate parser document without host-newline insertion. -+ public static SedScriptDocument Create( -+ IReadOnlyList sources -+ ) { -+ ArgumentNullException.ThrowIfNull( sources ); -+ if ( 0 == sources.Count ) { -+ throw new ArgumentException( "At least one script source is required.", nameof( sources ) ); -+ } -+ -+ var ordered = new List( sources.Count ); -+ var spans = new List( sources.Count ); -+ var text = new StringBuilder(); -+ for ( var index = 0; index < sources.Count; index++ ) { -+ var source = sources[ index ] ?? throw new ArgumentException( -+ "A script source cannot be null.", -+ nameof( sources ) -+ ); -+ if ( 0 < index && ( 0 == text.Length || '\n' != text[ ^1 ] ) ) { -+ text.Append( '\n' ); -+ } -+ var start = text.Length; -+ text.Append( source.Text ); -+ ordered.Add( source ); -+ spans.Add( new SourceSpan( source, start, source.Text.Length ) ); -+ } -+ return new SedScriptDocument( -+ ordered.AsReadOnly(), -+ text.ToString(), -+ spans.AsReadOnly() -+ ); -+ } -+ -+ /// Maps an aggregate character position back to its named source. -+ public SedScriptLocation GetLocation( -+ int position -+ ) { -+ if ( position < 0 ) { -+ position = 0; -+ } else if ( this.Text.Length < position ) { -+ position = this.Text.Length; -+ } -+ -+ SourceSpan span = this.mySpans[ ^1 ]; -+ foreach ( var candidate in this.mySpans ) { -+ if ( position <= candidate.Start + candidate.Length ) { -+ span = candidate; -+ break; -+ } -+ } -+ var local = Math.Clamp( position - span.Start, 0, span.Length ); -+ var line = 1; -+ var column = 1; -+ for ( var index = 0; index < local; index++ ) { -+ if ( '\n' == span.Source.Text[ index ] ) { -+ line++; -+ column = 1; -+ } else { -+ column++; -+ } -+ } -+ return new SedScriptLocation( span.Source.Name, line, column ); -+ } -+ -+ } -+ -+} -diff --git a/sed/src/SedScripting.cs b/sed/src/SedScripting.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..cce5d47f0f0b902bdcc9a452725ae35623f40491 ---- /dev/null -+++ b/sed/src/SedScripting.cs -@@ -0,0 +1,1513 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+using Icod.CommandFramework.Text; -+ -+// Responsibility: script instruction model and parser. -+public static partial class Command { -+ -+ private enum InstructionKind { -+ AppendText, -+ AppendHold, -+ AppendNext, -+ BeginGroup, -+ Branch, -+ ChangeText, -+ Delete, -+ DeleteFirst, -+ EndGroup, -+ Execute, -+ Exchange, -+ GetHold, -+ Label, -+ LineNumber, -+ List, -+ Next, -+ Print, -+ PrintFirst, -+ Quit, -+ QuitSilent, -+ ReadFile, -+ ReadFileLine, -+ SetHold, -+ Substitute, -+ TestBranch, -+ TestNoBranch, -+ Transliterate, -+ WriteFile, -+ WriteFirst -+ } -+ -+ -+ private sealed class Instruction { -+ -+ public AddressSelector? Address { -+ get; -+ } -+ -+ public object? Argument { -+ get; -+ } -+ -+ public InstructionKind Kind { -+ get; -+ } -+ -+ public int JumpIndex { -+ get; -+ set; -+ } = -1; -+ -+ public Instruction( -+ InstructionKind kind, -+ AddressSelector? address = null, -+ object? argument = null -+ ) { -+ this.Kind = kind; -+ this.Address = address; -+ this.Argument = argument; -+ } -+ -+ } -+ -+ private sealed class SedProgram { -+ -+ private readonly Dictionary myLabels; -+ -+ public IReadOnlyList Instructions { -+ get; -+ } -+ -+ public SedProgram( -+ IReadOnlyList instructions -+ ) { -+ this.Instructions = instructions; -+ this.myLabels = new Dictionary( -+ StringComparer.Ordinal -+ ); -+ -+ for ( -+ var index = 0; -+ index < instructions.Count; -+ index++ -+ ) { -+ var instruction = instructions[ index ]; -+ if ( -+ InstructionKind.Label == instruction.Kind -+ ) { -+ var label = instruction.Argument as string -+ ?? string.Empty -+ ; -+ if ( -+ this.myLabels.ContainsKey( -+ label -+ ) -+ ) { -+ throw new ScriptParseException( -+ $"duplicate label '{label}'" -+ ); -+ } -+ this.myLabels.Add( -+ label, -+ index -+ ); -+ } -+ } -+ -+ foreach ( var instruction in instructions ) { -+ if ( -+ ( -+ InstructionKind.Branch == instruction.Kind -+ || InstructionKind.TestBranch == instruction.Kind -+ || InstructionKind.TestNoBranch == instruction.Kind -+ ) -+ && instruction.Argument is string label -+ && 0 < label.Length -+ && !this.myLabels.ContainsKey( -+ label -+ ) -+ ) { -+ throw new ScriptParseException( -+ $"undefined label '{label}'" -+ ); -+ } -+ } -+ } -+ -+ public int ResolveLabel( -+ string? label -+ ) { -+ if ( string.IsNullOrEmpty( label ) ) { -+ return this.Instructions.Count; -+ } -+ -+ if ( -+ !this.myLabels.TryGetValue( -+ label, -+ out var index -+ ) -+ ) { -+ throw new ScriptParseException( -+ $"undefined label '{label}'" -+ ); -+ } -+ -+ return index; -+ } -+ -+ public void ResetAddresses() { -+ foreach ( var instruction in this.Instructions ) { -+ instruction.Address?.Reset(); -+ } -+ } -+ -+ } -+ -+ private sealed class ScriptParseException : Exception { -+ -+ public ScriptParseException( -+ string message -+ ) : base( -+ message -+ ) { -+ } -+ -+ } -+ -+ private sealed class ScriptParser { -+ -+ private readonly SedScriptDocument myDocument; -+ private readonly List myInstructions; -+ private readonly bool myPosix; -+ private readonly SedRegularExpressionCompiler myRegularExpressions; -+ private readonly bool mySandbox; -+ private readonly string myText; -+ private int myIndex; -+ -+ public ScriptParser( -+ SedScriptDocument document, -+ bool extendedRegularExpressions, -+ bool sandbox, -+ bool posix, -+ bool nullData, -+ ITextLocaleProvider textLocale, -+ CancellationToken cancellationToken -+ ) { -+ this.myDocument = document ?? throw new ArgumentNullException( -+ nameof( document ) -+ ); -+ this.myText = document.Text; -+ this.mySandbox = sandbox; -+ this.myPosix = posix; -+ this.myRegularExpressions = new SedRegularExpressionCompiler( -+ extendedRegularExpressions, -+ posix, -+ nullData, -+ textLocale, -+ cancellationToken -+ ); -+ this.myInstructions = new List(); -+ } -+ -+ public SedProgram Parse() { -+ this.ParseSequence( -+ stopAtClosingBrace: false -+ ); -+ this.SkipSeparators(); -+ if ( this.myIndex != this.myText.Length ) { -+ throw this.Error( -+ "unexpected script text" -+ ); -+ } -+ return new SedProgram( -+ this.myInstructions -+ ); -+ } -+ -+ private void ParseSequence( -+ bool stopAtClosingBrace -+ ) { -+ while ( this.myIndex < this.myText.Length ) { -+ this.SkipSeparators(); -+ if ( this.myIndex >= this.myText.Length ) { -+ if ( stopAtClosingBrace ) { -+ throw this.Error( -+ "unterminated command group" -+ ); -+ } -+ return; -+ } -+ -+ if ( '}' == this.myText[ this.myIndex ] ) { -+ if ( !stopAtClosingBrace ) { -+ throw this.Error( -+ "unexpected closing brace" -+ ); -+ } -+ this.myIndex++; -+ return; -+ } -+ -+ if ( '#' == this.myText[ this.myIndex ] ) { -+ this.SkipComment(); -+ continue; -+ } -+ -+ var selector = this.ParseSelector(); -+ this.SkipHorizontalWhitespace(); -+ -+ if ( this.myIndex >= this.myText.Length ) { -+ throw this.Error( -+ "missing command" -+ ); -+ } -+ -+ var command = this.myText[ this.myIndex ]; -+ switch ( command ) { -+ case '#': -+ if ( null != selector ) { -+ throw this.Error( -+ "comments cannot have addresses" -+ ); -+ } -+ this.SkipComment(); -+ break; -+ -+ case ':': -+ if ( null != selector ) { -+ throw this.Error( -+ "labels cannot have addresses" -+ ); -+ } -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Label, -+ argument: this.ReadSimpleArgument() -+ ) -+ ); -+ break; -+ -+ case '{': { -+ this.myIndex++; -+ var begin = new Instruction( -+ InstructionKind.BeginGroup, -+ selector -+ ); -+ this.myInstructions.Add( -+ begin -+ ); -+ this.ParseSequence( -+ stopAtClosingBrace: true -+ ); -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.EndGroup -+ ) -+ ); -+ begin.JumpIndex = this.myInstructions.Count; -+ break; -+ } -+ -+ case '=': -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.LineNumber, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'a': -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.AppendText, -+ selector, -+ this.ReadTextArgument() -+ ) -+ ); -+ break; -+ -+ case 'b': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Branch, -+ selector, -+ this.ReadSimpleArgument() -+ ) -+ ); -+ break; -+ -+ case 'c': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.ChangeText, -+ selector, -+ this.ReadTextArgument() -+ ) -+ ); -+ break; -+ -+ case 'd': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Delete, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'D': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.DeleteFirst, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'e': -+ this.RequireGnuExtension( -+ command -+ ); -+ this.RequireFileAccess(); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Execute, -+ selector, -+ this.ReadSimpleArgument() -+ ) -+ ); -+ break; -+ -+ case 'g': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.GetHold, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'G': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.AppendHold, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'h': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.SetHold, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'H': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.AppendHold, -+ selector, -+ argument: true -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'i': -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Print, -+ selector, -+ new InsertArgument( -+ this.ReadTextArgument() -+ ) -+ ) -+ ); -+ break; -+ -+ case 'l': -+ this.myIndex++; -+ this.SkipHorizontalWhitespace(); -+ var listWidth = this.ReadOptionalInteger(); -+ if ( -+ this.myPosix -+ && listWidth.HasValue -+ ) { -+ throw this.Error( -+ "the l command width is not available in POSIX mode" -+ ); -+ } -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.List, -+ selector, -+ listWidth -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'n': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Next, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'N': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.AppendNext, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'p': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Print, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'P': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.PrintFirst, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'q': -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.SkipHorizontalWhitespace(); -+ var quitExitCode = this.ReadOptionalInteger(); -+ if ( -+ this.myPosix -+ && quitExitCode.HasValue -+ ) { -+ throw this.Error( -+ "the q command exit code is not available in POSIX mode" -+ ); -+ } -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Quit, -+ selector, -+ quitExitCode -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'Q': -+ this.RequireGnuExtension( -+ command -+ ); -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.SkipHorizontalWhitespace(); -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.QuitSilent, -+ selector, -+ this.ReadOptionalInteger() -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'r': -+ this.RequireFileAccess(); -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.ReadFile, -+ selector, -+ this.ReadFileArgument() -+ ) -+ ); -+ break; -+ -+ case 'R': -+ this.RequireGnuExtension( -+ command -+ ); -+ this.RequireFileAccess(); -+ this.RequireAtMostOneAddress( -+ selector, -+ command -+ ); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.ReadFileLine, -+ selector, -+ this.ReadFileArgument() -+ ) -+ ); -+ break; -+ -+ case 's': -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Substitute, -+ selector, -+ this.ParseSubstitution() -+ ) -+ ); -+ break; -+ -+ case 't': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.TestBranch, -+ selector, -+ this.ReadSimpleArgument() -+ ) -+ ); -+ break; -+ -+ case 'T': -+ this.RequireGnuExtension( -+ command -+ ); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.TestNoBranch, -+ selector, -+ this.ReadSimpleArgument() -+ ) -+ ); -+ break; -+ -+ case 'w': -+ this.RequireFileAccess(); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.WriteFile, -+ selector, -+ this.ReadFileArgument() -+ ) -+ ); -+ break; -+ -+ case 'W': -+ this.RequireGnuExtension( -+ command -+ ); -+ this.RequireFileAccess(); -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.WriteFirst, -+ selector, -+ this.ReadFileArgument() -+ ) -+ ); -+ break; -+ -+ case 'x': -+ this.myIndex++; -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Exchange, -+ selector -+ ) -+ ); -+ this.RequireBoundary(); -+ break; -+ -+ case 'y': -+ this.myInstructions.Add( -+ new Instruction( -+ InstructionKind.Transliterate, -+ selector, -+ this.ParseTransliteration() -+ ) -+ ); -+ break; -+ -+ default: -+ throw this.Error( -+ $"unsupported command '{command}'" -+ ); -+ } -+ } -+ -+ if ( stopAtClosingBrace ) { -+ throw this.Error( -+ "unterminated command group" -+ ); -+ } -+ } -+ -+ private AddressSelector? ParseSelector() { -+ var save = this.myIndex; -+ var first = this.TryParseAddress( -+ allowRangeEndSpecialForms: false -+ ); -+ if ( null == first ) { -+ this.myIndex = save; -+ return null; -+ } -+ -+ this.SkipHorizontalWhitespace(); -+ RangeEnd? second = null; -+ if ( -+ this.myIndex < this.myText.Length -+ && ',' == this.myText[ this.myIndex ] -+ ) { -+ this.myIndex++; -+ this.SkipHorizontalWhitespace(); -+ second = this.ParseRangeEnd(); -+ } -+ -+ this.SkipHorizontalWhitespace(); -+ var negated = false; -+ if ( -+ this.myIndex < this.myText.Length -+ && '!' == this.myText[ this.myIndex ] -+ ) { -+ negated = true; -+ this.myIndex++; -+ } -+ -+ return new AddressSelector( -+ first, -+ second, -+ negated -+ ); -+ } -+ -+ private Address? TryParseAddress( -+ bool allowRangeEndSpecialForms -+ ) { -+ if ( this.myIndex >= this.myText.Length ) { -+ return null; -+ } -+ -+ var character = this.myText[ this.myIndex ]; -+ if ( '$' == character ) { -+ this.myIndex++; -+ return new LastLineAddress(); -+ } -+ -+ if ( char.IsDigit( character ) ) { -+ var number = this.ReadInteger( -+ allowZero: true -+ ); -+ if ( -+ this.myIndex < this.myText.Length -+ && '~' == this.myText[ this.myIndex ] -+ ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ "step addresses are not available in POSIX mode" -+ ); -+ } -+ this.myIndex++; -+ var step = this.ReadInteger( -+ allowZero: false -+ ); -+ return new StepAddress( -+ number, -+ step -+ ); -+ } -+ -+ if ( 0 == number ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ "address 0 is not available in POSIX mode" -+ ); -+ } -+ return new ZeroAddress(); -+ } -+ return new LineAddress( -+ number -+ ); -+ } -+ -+ if ( '/' == character ) { -+ this.myIndex++; -+ var pattern = this.ReadDelimited( -+ '/' -+ ); -+ this.ReadAddressRegularExpressionModifiers( -+ out var ignoreCase, -+ out var multiline -+ ); -+ return this.CreateRegexAddress( -+ pattern, -+ ignoreCase, -+ multiline -+ ); -+ } -+ -+ if ( -+ '\\' == character -+ && this.myIndex + 1 < this.myText.Length -+ ) { -+ this.myIndex++; -+ var delimiter = this.myText[ this.myIndex ]; -+ this.myIndex++; -+ var pattern = this.ReadDelimited( -+ delimiter -+ ); -+ this.ReadAddressRegularExpressionModifiers( -+ out var ignoreCase, -+ out var multiline -+ ); -+ return this.CreateRegexAddress( -+ pattern, -+ ignoreCase, -+ multiline -+ ); -+ } -+ -+ return null; -+ } -+ -+ private RangeEnd ParseRangeEnd() { -+ if ( this.myIndex >= this.myText.Length ) { -+ throw this.Error( -+ "missing range end" -+ ); -+ } -+ -+ if ( '+' == this.myText[ this.myIndex ] ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ "relative range addresses are not available in POSIX mode" -+ ); -+ } -+ this.myIndex++; -+ return new RelativeRangeEnd( -+ this.ReadInteger( -+ allowZero: true -+ ) -+ ); -+ } -+ -+ if ( '~' == this.myText[ this.myIndex ] ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ "multiple range addresses are not available in POSIX mode" -+ ); -+ } -+ this.myIndex++; -+ return new MultipleRangeEnd( -+ this.ReadInteger( -+ allowZero: false -+ ) -+ ); -+ } -+ -+ var address = this.TryParseAddress( -+ allowRangeEndSpecialForms: true -+ ) ?? throw this.Error( -+ "missing range end" -+ ); -+ return new AddressRangeEnd( -+ address -+ ); -+ } -+ -+ private Substitution ParseSubstitution() { -+ this.myIndex++; -+ if ( this.myIndex >= this.myText.Length ) { -+ throw this.Error( -+ "substitution is missing its delimiter" -+ ); -+ } -+ -+ var delimiter = this.myText[ this.myIndex ]; -+ this.myIndex++; -+ -+ var pattern = this.ReadDelimited( -+ delimiter -+ ); -+ var replacement = this.ReadDelimited( -+ delimiter -+ ); -+ -+ var flagStart = this.myIndex; -+ while ( -+ this.myIndex < this.myText.Length -+ && !this.IsCommandSeparator( -+ this.myText[ this.myIndex ] -+ ) -+ && '}' != this.myText[ this.myIndex ] -+ ) { -+ this.myIndex++; -+ } -+ -+ var flags = this.myText.Substring( -+ flagStart, -+ this.myIndex - flagStart -+ ).Trim(); -+ -+ this.ValidateSubstitutionFlags( -+ flags -+ ); -+ var parsedFlags = ParseSubstitutionFlags( -+ flags -+ ); -+ var regularExpression = this.CompileRegularExpression( -+ pattern, -+ SedRegularExpressionContext.Substitution, -+ parsedFlags.IgnoreCase, -+ parsedFlags.Multiline -+ ); -+ -+ return new Substitution( -+ regularExpression, -+ replacement, -+ flags -+ ); -+ } -+ -+ private Transliteration ParseTransliteration() { -+ this.myIndex++; -+ if ( this.myIndex >= this.myText.Length ) { -+ throw this.Error( -+ "transliteration is missing its delimiter" -+ ); -+ } -+ -+ var delimiter = this.myText[ this.myIndex ]; -+ this.myIndex++; -+ var source = this.ReadDelimited( -+ delimiter -+ ); -+ var destination = this.ReadDelimited( -+ delimiter -+ ); -+ this.RequireBoundary(); -+ if ( -+ ExpandCharacterSet( -+ source -+ ).Length -+ != ExpandCharacterSet( -+ destination -+ ).Length -+ ) { -+ throw this.Error( -+ "the y command source and destination must have equal lengths" -+ ); -+ } -+ -+ return new Transliteration( -+ source, -+ destination -+ ); -+ } -+ -+ private string ReadDelimited( -+ char delimiter -+ ) { -+ var output = new StringBuilder(); -+ var escaped = false; -+ -+ while ( this.myIndex < this.myText.Length ) { -+ var character = this.myText[ this.myIndex ]; -+ this.myIndex++; -+ -+ if ( escaped ) { -+ if ( delimiter == character ) { -+ output.Append( -+ character -+ ); -+ } else { -+ output.Append( -+ '\\' -+ ); -+ output.Append( -+ character -+ ); -+ } -+ escaped = false; -+ } else if ( '\\' == character ) { -+ escaped = true; -+ } else if ( delimiter == character ) { -+ return output.ToString(); -+ } else { -+ output.Append( -+ character -+ ); -+ } -+ } -+ -+ throw this.Error( -+ $"unterminated expression using delimiter '{delimiter}'" -+ ); -+ } -+ -+ private string ReadTextArgument() { -+ this.SkipHorizontalWhitespace(); -+ if ( -+ this.myIndex < this.myText.Length -+ && '\\' == this.myText[ this.myIndex ] -+ ) { -+ this.myIndex++; -+ if ( -+ this.myIndex < this.myText.Length -+ && '\r' == this.myText[ this.myIndex ] -+ ) { -+ this.myIndex++; -+ } -+ if ( -+ this.myIndex < this.myText.Length -+ && '\n' == this.myText[ this.myIndex ] -+ ) { -+ this.myIndex++; -+ } -+ } -+ -+ return UnescapeSedText( -+ this.ReadUntilCommandSeparator() -+ ); -+ } -+ -+ private string ReadFileArgument() { -+ this.SkipHorizontalWhitespace(); -+ var output = this.ReadUntilCommandSeparator().Trim(); -+ if ( 0 == output.Length ) { -+ throw this.Error( -+ "missing file name" -+ ); -+ } -+ return output; -+ } -+ -+ private string ReadSimpleArgument() { -+ this.SkipHorizontalWhitespace(); -+ return this.ReadUntilCommandSeparator().Trim(); -+ } -+ -+ private string ReadUntilCommandSeparator() { -+ var output = new StringBuilder(); -+ var escaped = false; -+ -+ while ( this.myIndex < this.myText.Length ) { -+ var character = this.myText[ this.myIndex ]; -+ if ( -+ !escaped -+ && ( -+ this.IsCommandSeparator( -+ character -+ ) -+ || '}' == character -+ ) -+ ) { -+ break; -+ } -+ -+ this.myIndex++; -+ if ( escaped ) { -+ output.Append( -+ character -+ ); -+ escaped = false; -+ } else if ( '\\' == character ) { -+ escaped = true; -+ output.Append( -+ character -+ ); -+ } else { -+ output.Append( -+ character -+ ); -+ } -+ } -+ -+ return output.ToString(); -+ } -+ -+ private int? ReadOptionalInteger() { -+ if ( -+ this.myIndex >= this.myText.Length -+ || !char.IsDigit( -+ this.myText[ this.myIndex ] -+ ) -+ ) { -+ return null; -+ } -+ return this.ReadInteger( -+ allowZero: true -+ ); -+ } -+ -+ private int ReadInteger( -+ bool allowZero -+ ) { -+ var start = this.myIndex; -+ while ( -+ this.myIndex < this.myText.Length -+ && char.IsDigit( -+ this.myText[ this.myIndex ] -+ ) -+ ) { -+ this.myIndex++; -+ } -+ -+ if ( -+ start == this.myIndex -+ || !int.TryParse( -+ this.myText.Substring( -+ start, -+ this.myIndex - start -+ ), -+ NumberStyles.None, -+ CultureInfo.InvariantCulture, -+ out var output -+ ) -+ || ( -+ !allowZero -+ && output <= 0 -+ ) -+ ) { -+ throw this.Error( -+ "invalid numeric argument" -+ ); -+ } -+ -+ return output; -+ } -+ -+ private SedCompiledRegularExpression CompileRegularExpression( -+ string pattern, -+ SedRegularExpressionContext context, -+ bool ignoreCase, -+ bool multiline -+ ) { -+ try { -+ return this.myRegularExpressions.Compile( -+ pattern, -+ context, -+ ignoreCase, -+ multiline -+ ); -+ } catch ( ScriptParseException ex ) { -+ throw this.Error( -+ ex.Message -+ ); -+ } -+ } -+ -+ private RegexAddress CreateRegexAddress( -+ string pattern, -+ bool ignoreCase, -+ bool multiline -+ ) { -+ return new RegexAddress( -+ this.CompileRegularExpression( -+ pattern, -+ SedRegularExpressionContext.Address, -+ ignoreCase, -+ multiline -+ ) -+ ); -+ } -+ -+ private void ReadAddressRegularExpressionModifiers( -+ out bool ignoreCase, -+ out bool multiline -+ ) { -+ ignoreCase = false; -+ multiline = false; -+ while ( this.myIndex < this.myText.Length ) { -+ var modifier = this.myText[ this.myIndex ]; -+ if ( 'I' == modifier ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ "regular-expression address modifiers are not available in POSIX mode" -+ ); -+ } -+ ignoreCase = true; -+ this.myIndex++; -+ } else if ( 'M' == modifier ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ "regular-expression address modifiers are not available in POSIX mode" -+ ); -+ } -+ multiline = true; -+ this.myIndex++; -+ } else { -+ break; -+ } -+ } -+ } -+ -+ private void RequireGnuExtension( -+ char command -+ ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ $"command '{command}' is not available in POSIX mode" -+ ); -+ } -+ } -+ -+ private void ValidateSubstitutionFlags( -+ string flags -+ ) { -+ var index = 0; -+ var occurrenceSeen = false; -+ while ( index < flags.Length ) { -+ var character = flags[ index ]; -+ if ( char.IsWhiteSpace( character ) ) { -+ index++; -+ continue; -+ } -+ if ( char.IsDigit( character ) ) { -+ if ( occurrenceSeen ) { -+ throw this.Error( -+ "multiple substitution occurrence numbers" -+ ); -+ } -+ occurrenceSeen = true; -+ var occurrenceStart = index; -+ while ( -+ index < flags.Length -+ && char.IsDigit( flags[ index ] ) -+ ) { -+ index++; -+ } -+ if ( -+ !int.TryParse( -+ flags.Substring( -+ occurrenceStart, -+ index - occurrenceStart -+ ), -+ NumberStyles.None, -+ CultureInfo.InvariantCulture, -+ out var occurrence -+ ) -+ || occurrence <= 0 -+ ) { -+ throw this.Error( -+ "substitution occurrence must be a positive integer" -+ ); -+ } -+ continue; -+ } -+ if ( -+ 'g' == character -+ || 'p' == character -+ ) { -+ index++; -+ continue; -+ } -+ if ( 'w' == character ) { -+ if ( this.mySandbox ) { -+ throw this.Error( -+ "the substitution w flag is disabled in sandbox mode" -+ ); -+ } -+ index++; -+ while ( -+ index < flags.Length -+ && char.IsWhiteSpace( flags[ index ] ) -+ ) { -+ index++; -+ } -+ if ( index >= flags.Length ) { -+ throw this.Error( -+ "the substitution w flag requires a file name" -+ ); -+ } -+ return; -+ } -+ if ( -+ 'i' == character -+ || 'I' == character -+ || 'm' == character -+ || 'M' == character -+ || 'e' == character -+ ) { -+ if ( this.myPosix ) { -+ throw this.Error( -+ $"substitution flag '{character}' is not available in POSIX mode" -+ ); -+ } -+ if ( -+ 'e' == character -+ && this.mySandbox -+ ) { -+ throw this.Error( -+ "the substitution e flag is disabled in sandbox mode" -+ ); -+ } -+ index++; -+ continue; -+ } -+ throw this.Error( -+ $"unknown substitution flag '{character}'" -+ ); -+ } -+ } -+ -+ private void RequireAtMostOneAddress( -+ AddressSelector? selector, -+ char command -+ ) { -+ if ( -+ null != selector -+ && selector.HasRange -+ ) { -+ throw this.Error( -+ $"command '{command}' accepts at most one address" -+ ); -+ } -+ } -+ -+ private void RequireFileAccess() { -+ if ( this.mySandbox ) { -+ throw this.Error( -+ "file access commands are disabled in sandbox mode" -+ ); -+ } -+ } -+ -+ private void RequireBoundary() { -+ if ( -+ this.myIndex < this.myText.Length -+ && !this.IsCommandSeparator( -+ this.myText[ this.myIndex ] -+ ) -+ && '}' != this.myText[ this.myIndex ] -+ && !char.IsWhiteSpace( -+ this.myText[ this.myIndex ] -+ ) -+ ) { -+ throw this.Error( -+ "unexpected text after command" -+ ); -+ } -+ } -+ -+ private void SkipComment() { -+ while ( -+ this.myIndex < this.myText.Length -+ && '\n' != this.myText[ this.myIndex ] -+ ) { -+ this.myIndex++; -+ } -+ } -+ -+ private void SkipHorizontalWhitespace() { -+ while ( -+ this.myIndex < this.myText.Length -+ && ( -+ ' ' == this.myText[ this.myIndex ] -+ || '\t' == this.myText[ this.myIndex ] -+ ) -+ ) { -+ this.myIndex++; -+ } -+ } -+ -+ private void SkipSeparators() { -+ while ( this.myIndex < this.myText.Length ) { -+ var character = this.myText[ this.myIndex ]; -+ if ( -+ ';' == character -+ || '\r' == character -+ || '\n' == character -+ || ' ' == character -+ || '\t' == character -+ ) { -+ this.myIndex++; -+ } else { -+ break; -+ } -+ } -+ } -+ -+ private bool IsCommandSeparator( -+ char character -+ ) { -+ return ( -+ ';' == character -+ || '\r' == character -+ || '\n' == character -+ ); -+ } -+ -+ private ScriptParseException Error( -+ string message -+ ) { -+ var location = this.myDocument.GetLocation( this.myIndex ); -+ return new ScriptParseException( -+ $"{message} at {location.SourceName}:{location.Line}:{location.Column}" -+ ); -+ } -+ -+ } -+ -+ -+ private sealed class InsertArgument { -+ -+ public string Text { -+ get; -+ } -+ -+ public InsertArgument( -+ string text -+ ) { -+ this.Text = text; -+ } -+ -+ } -+ -+ -+ private static string UnescapeSedText( -+ string value -+ ) { -+ var output = new StringBuilder( -+ value.Length -+ ); -+ for ( -+ var index = 0; -+ index < value.Length; -+ index++ -+ ) { -+ var character = value[ index ]; -+ if ( -+ '\\' == character -+ && index + 1 < value.Length -+ ) { -+ index++; -+ output.Append( -+ UnescapeCharacter( -+ value[ index ] -+ ) -+ ); -+ } else { -+ output.Append( -+ character -+ ); -+ } -+ } -+ return output.ToString(); -+ } -+ -+ private static char UnescapeCharacter( -+ char character -+ ) { -+ return character switch { -+ 'a' => '\a', -+ 'b' => '\b', -+ 'f' => '\f', -+ 'n' => '\n', -+ 'r' => '\r', -+ 't' => '\t', -+ 'v' => '\v', -+ _ => character -+ }; -+ } -+ -+ -+ private static async Task ReadScriptFileAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ using ( var reader = new StreamReader( -+ new FileStream( -+ path, -+ FileMode.Open, -+ FileAccess.Read, -+ FileShare.Read, -+ 8192, -+ useAsync: true -+ ), -+ Encoding.UTF8, -+ detectEncodingFromByteOrderMarks: true, -+ bufferSize: 8192, -+ leaveOpen: false -+ ) ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ return await reader.ReadToEndAsync( -+ cancellationToken -+ ).ConfigureAwait( false ); -+ } -+ } -+ -+ -+} -diff --git a/sed/src/SedSubstitution.cs b/sed/src/SedSubstitution.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..532a178a8d81e56638167f64226c402eae3442c9 ---- /dev/null -+++ b/sed/src/SedSubstitution.cs -@@ -0,0 +1,424 @@ -+namespace Icod.LineEditor.Sed; -+ -+using System; -+using System.Collections.Generic; -+using System.Globalization; -+using System.IO; -+using System.Linq; -+using System.Text; -+using Icod.CommandFramework.RegularExpressions; -+using System.Threading; -+using System.Threading.Tasks; -+using Icod.CommandFramework.CommandLine; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.IO; -+using Icod.CommandFramework.Processes; -+ -+// Responsibility: substitution and transliteration. -+public static partial class Command { -+ -+ private sealed class Substitution { -+ -+ public string Flags { -+ get; -+ } -+ -+ public SedCompiledRegularExpression RegularExpression { -+ get; -+ } -+ -+ public string Replacement { -+ get; -+ } -+ -+ public Substitution( -+ SedCompiledRegularExpression regularExpression, -+ string replacement, -+ string flags -+ ) { -+ this.RegularExpression = regularExpression -+ ?? throw new ArgumentNullException( -+ nameof( regularExpression ) -+ ) -+ ; -+ this.Replacement = replacement; -+ this.Flags = flags; -+ } -+ -+ } -+ -+ private sealed class Transliteration { -+ -+ public string Destination { -+ get; -+ } -+ -+ public string Source { -+ get; -+ } -+ -+ public Transliteration( -+ string source, -+ string destination -+ ) { -+ this.Source = source; -+ this.Destination = destination; -+ } -+ -+ } -+ -+ -+ private sealed class SubstitutionFlags { -+ -+ public bool Execute { -+ get; -+ set; -+ } -+ -+ public bool Global { -+ get; -+ set; -+ } -+ -+ public bool IgnoreCase { -+ get; -+ set; -+ } -+ -+ public bool Multiline { -+ get; -+ set; -+ } -+ -+ public int? Occurrence { -+ get; -+ set; -+ } -+ -+ public bool Print { -+ get; -+ set; -+ } -+ -+ public string? WriteFile { -+ get; -+ set; -+ } -+ -+ } -+ -+ private static SubstitutionFlags ParseSubstitutionFlags( -+ string flags -+ ) { -+ var output = new SubstitutionFlags(); -+ var index = 0; -+ -+ while ( index < flags.Length ) { -+ var character = flags[ index ]; -+ if ( char.IsWhiteSpace( character ) ) { -+ index++; -+ } else if ( char.IsDigit( character ) ) { -+ var start = index; -+ while ( -+ index < flags.Length -+ && char.IsDigit( -+ flags[ index ] -+ ) -+ ) { -+ index++; -+ } -+ output.Occurrence = int.Parse( -+ flags.Substring( -+ start, -+ index - start -+ ), -+ CultureInfo.InvariantCulture -+ ); -+ } else if ( 'e' == character ) { -+ output.Execute = true; -+ index++; -+ } else if ( 'g' == character ) { -+ output.Global = true; -+ index++; -+ } else if ( 'p' == character ) { -+ output.Print = true; -+ index++; -+ } else if ( -+ 'i' == character -+ || 'I' == character -+ ) { -+ output.IgnoreCase = true; -+ index++; -+ } else if ( -+ 'm' == character -+ || 'M' == character -+ ) { -+ output.Multiline = true; -+ index++; -+ } else if ( 'w' == character ) { -+ index++; -+ while ( -+ index < flags.Length -+ && char.IsWhiteSpace( -+ flags[ index ] -+ ) -+ ) { -+ index++; -+ } -+ output.WriteFile = flags.Substring( -+ index -+ ).Trim(); -+ break; -+ } else { -+ index++; -+ } -+ } -+ -+ return output; -+ } -+ -+ private static string ApplySubstitution( -+ string input, -+ Substitution substitution, -+ out bool replaced, -+ CancellationToken cancellationToken -+ ) { -+ var flags = ParseSubstitutionFlags( -+ substitution.Flags -+ ); -+ var matches = substitution.RegularExpression.FindMatches( -+ input, -+ cancellationToken -+ ); -+ if ( 0 == matches.Count ) { -+ replaced = false; -+ return input; -+ } -+ -+ var first = flags.Occurrence ?? 1; -+ if ( -+ first <= 0 -+ || matches.Count < first -+ ) { -+ replaced = false; -+ return input; -+ } -+ -+ var output = new StringBuilder( -+ input.Length -+ ); -+ var cursor = 0; -+ var replacementCount = 0; -+ -+ for ( var index = 0; index < matches.Count; index++ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ var matchNumber = index + 1; -+ var shouldReplace = flags.Global -+ ? first <= matchNumber -+ : first == matchNumber -+ ; -+ if ( !shouldReplace ) { -+ continue; -+ } -+ -+ var match = matches[ index ]; -+ output.Append( -+ input, -+ cursor, -+ match.Index - cursor -+ ); -+ output.Append( -+ ExpandReplacement( -+ substitution.Replacement, -+ match -+ ) -+ ); -+ cursor = match.Index + match.Length; -+ replacementCount++; -+ -+ if ( !flags.Global ) { -+ break; -+ } -+ } -+ -+ if ( 0 == replacementCount ) { -+ replaced = false; -+ return input; -+ } -+ -+ output.Append( -+ input, -+ cursor, -+ input.Length - cursor -+ ); -+ replaced = true; -+ return output.ToString(); -+ } -+ -+ private static string ExpandReplacement( -+ string replacement, -+ RegularExpressionMatch match -+ ) { -+ var output = new StringBuilder(); -+ -+ for ( -+ var index = 0; -+ index < replacement.Length; -+ index++ -+ ) { -+ var character = replacement[ index ]; -+ if ( '&' == character ) { -+ output.Append( -+ match.Value -+ ); -+ } else if ( -+ '\\' == character -+ && index + 1 < replacement.Length -+ ) { -+ index++; -+ var escaped = replacement[ index ]; -+ if ( -+ '0' <= escaped -+ && escaped <= '9' -+ ) { -+ var groupNumber = escaped - '0'; -+ if ( 0 == groupNumber ) { -+ output.Append( -+ match.Value -+ ); -+ } else if ( groupNumber <= match.Captures.Count ) { -+ var capture = match.Captures[ groupNumber - 1 ]; -+ if ( capture.Success ) { -+ output.Append( -+ capture.Value -+ ); -+ } -+ } -+ } else { -+ switch ( escaped ) { -+ case 'n': -+ output.Append( -+ '\n' -+ ); -+ break; -+ case 'r': -+ output.Append( -+ '\r' -+ ); -+ break; -+ case 't': -+ output.Append( -+ '\t' -+ ); -+ break; -+ default: -+ output.Append( -+ escaped -+ ); -+ break; -+ } -+ } -+ } else { -+ output.Append( -+ character -+ ); -+ } -+ } -+ -+ return output.ToString(); -+ } -+ -+ private static string Transliterate( -+ string input, -+ Transliteration transliteration -+ ) { -+ var source = ExpandCharacterSet( -+ transliteration.Source -+ ); -+ var destination = ExpandCharacterSet( -+ transliteration.Destination -+ ); -+ if ( source.Length != destination.Length ) { -+ throw new ScriptParseException( -+ "the y command source and destination must have equal lengths" -+ ); -+ } -+ -+ var map = new Dictionary(); -+ for ( -+ var index = 0; -+ index < source.Length; -+ index++ -+ ) { -+ map[ source[ index ] ] = destination[ index ]; -+ } -+ -+ var output = input.ToCharArray(); -+ for ( -+ var index = 0; -+ index < output.Length; -+ index++ -+ ) { -+ if ( -+ map.TryGetValue( -+ output[ index ], -+ out var replacement -+ ) -+ ) { -+ output[ index ] = replacement; -+ } -+ } -+ return new string( -+ output -+ ); -+ } -+ -+ private static string ExpandCharacterSet( -+ string value -+ ) { -+ var output = new StringBuilder(); -+ -+ for ( -+ var index = 0; -+ index < value.Length; -+ index++ -+ ) { -+ var character = value[ index ]; -+ if ( -+ index + 2 < value.Length -+ && '-' == value[ index + 1 ] -+ && character <= value[ index + 2 ] -+ ) { -+ var end = value[ index + 2 ]; -+ for ( -+ var current = character; -+ current <= end; -+ current++ -+ ) { -+ output.Append( -+ current -+ ); -+ } -+ index += 2; -+ } else if ( -+ '\\' == character -+ && index + 1 < value.Length -+ ) { -+ index++; -+ output.Append( -+ UnescapeCharacter( -+ value[ index ] -+ ) -+ ); -+ } else { -+ output.Append( -+ character -+ ); -+ } -+ } -+ -+ return output.ToString(); -+ } -+ -+ -+} -diff --git a/tests/Ed.Shared.Tests/Icod.LineEditor.Ed.Shared.Tests.csproj b/tests/Ed.Shared.Tests/Icod.LineEditor.Ed.Shared.Tests.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..5c7fe6b46bef3e96a34b2d4356a816af0440e364 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/Icod.LineEditor.Ed.Shared.Tests.csproj -@@ -0,0 +1,57 @@ -+ -+ -+ -+ net10.0 -+ 13.0 -+ enable -+ enable -+ false -+ true -+ Icod.LineEditor.Ed.Shared.Tests -+ Icod.LineEditor.Ed.Shared.Tests -+ -+ -+ -+ -+ -+ runtime; build; native; contentfiles; analyzers; buildtransitive -+ all -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/tests/Ed.Shared.Tests/README.md b/tests/Ed.Shared.Tests/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..0048ea59548b16ef7881384447742c85a39089dd ---- /dev/null -+++ b/tests/Ed.Shared.Tests/README.md -@@ -0,0 +1,15 @@ -+# Icod.LineEditor.Ed.Shared.Tests -+ -+The dedicated Phase LE6 test project covers the reusable Ed/Red engine independently from the `ed` and `red` command executables. -+ -+Coverage includes: -+ -+- stable line identity across moves, copies, joins, large segmented edits, and undo snapshots; -+- address, mark, cut-buffer, mutation, substitution, global-command, and remembered-state behavior; -+- Shared GNU BRE integration and replacement back-references; -+- injected file and process capabilities; -+- restricted parser/dispatcher denial and restricted file resolution; -+- cancellation and controlled exit statuses; -+- textual ed-script fixtures representing GNU Diffutils and `Icod.DiffUtils` output. -+ -+Command-line conformance belongs to LE7 and restricted-command adversarial conformance belongs to LE8. -diff --git a/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/change.ed b/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/change.ed -new file mode 100644 -index 0000000000000000000000000000000000000000..6fe7d1f3d6f247f6c6114d0fda196f56d568360c ---- /dev/null -+++ b/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/change.ed -@@ -0,0 +1,6 @@ -+2c -+TWO -+. -+4a -+five -+. -diff --git a/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/expected.txt b/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/expected.txt -new file mode 100644 -index 0000000000000000000000000000000000000000..02b50541631ed966cb2a96d6073b203917fb7a17 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/expected.txt -@@ -0,0 +1,5 @@ -+one -+TWO -+three -+four -+five -diff --git a/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/original.txt b/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/original.txt -new file mode 100644 -index 0000000000000000000000000000000000000000..f384549cbeb481e437091320de6d1f2e15e11b4a ---- /dev/null -+++ b/tests/Ed.Shared.Tests/fixtures/gnu-diffutils/original.txt -@@ -0,0 +1,4 @@ -+one -+two -+three -+four -diff --git a/tests/Ed.Shared.Tests/fixtures/icod-diffutils/change.ed b/tests/Ed.Shared.Tests/fixtures/icod-diffutils/change.ed -new file mode 100644 -index 0000000000000000000000000000000000000000..f855f2c74469d1ff77012a70b82ba342416a2df6 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/fixtures/icod-diffutils/change.ed -@@ -0,0 +1,5 @@ -+1a -+inserted -+. -+3d -+4s/delta/DELTA/ -diff --git a/tests/Ed.Shared.Tests/fixtures/icod-diffutils/expected.txt b/tests/Ed.Shared.Tests/fixtures/icod-diffutils/expected.txt -new file mode 100644 -index 0000000000000000000000000000000000000000..6e0c580ee1b2480fc701856753aaea15ef0d3924 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/fixtures/icod-diffutils/expected.txt -@@ -0,0 +1,4 @@ -+alpha -+inserted -+gamma -+DELTA -diff --git a/tests/Ed.Shared.Tests/fixtures/icod-diffutils/original.txt b/tests/Ed.Shared.Tests/fixtures/icod-diffutils/original.txt -new file mode 100644 -index 0000000000000000000000000000000000000000..7a28df3c975fa62270a452251c4e0b24d685c4ba ---- /dev/null -+++ b/tests/Ed.Shared.Tests/fixtures/icod-diffutils/original.txt -@@ -0,0 +1,4 @@ -+alpha -+beta -+gamma -+delta -diff --git a/tests/Ed.Shared.Tests/src/ArchitectureBoundaryTests.cs b/tests/Ed.Shared.Tests/src/ArchitectureBoundaryTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..ea44a0a782883f5a763d48197e6f5e0573aa1ad5 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/ArchitectureBoundaryTests.cs -@@ -0,0 +1,32 @@ -+namespace Icod.LineEditor.Ed.Shared.Tests; -+ -+using Icod.LineEditor.Ed; -+ -+/// -+/// Locks the dependency direction established by the Phase LE9 sharing audit. -+/// -+public sealed class ArchitectureBoundaryTests { -+ /// -+ /// Verifies that the Ed/Red engine consumes the neutral command framework -+ /// without taking a dependency on Sed, an executable, or a -+ /// speculative LineEditor-family wrapper. -+ /// -+ [Fact] -+ public void EdSharedReferencesOnlyTheNeutralFoundationWithinTheFamily() { -+ var references = typeof( EditorEngine ) -+ .Assembly -+ .GetReferencedAssemblies() -+ .Select( reference => reference.Name ?? string.Empty ) -+ .ToArray(); -+ -+ Assert.Contains( "Icod.CommandFramework", references ); -+ Assert.DoesNotContain( "Icod.CoreUtils.Shared", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Sed", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Shared", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Ed", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Red", references ); -+ Assert.DoesNotContain( "ed", references ); -+ Assert.DoesNotContain( "red", references ); -+ Assert.DoesNotContain( "sed", references ); -+ } -+} -diff --git a/tests/Ed.Shared.Tests/src/EditorBufferTests.cs b/tests/Ed.Shared.Tests/src/EditorBufferTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..0fdd3fe5aa26345f7dd2b17e30f007d4faf55ba9 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/EditorBufferTests.cs -@@ -0,0 +1,60 @@ -+namespace Icod.LineEditor.Ed.Shared.Tests; -+ -+using System.Text; -+using Icod.LineEditor.Ed; -+ -+public sealed class EditorBufferTests { -+ [Fact] -+ public void MovePreservesStableIdentityAndCopyAllocatesNewIdentity() { -+ var buffer = new EditorBuffer(); -+ buffer.Append( Lines( "one", "two", "three", "four" ) ); -+ var identity = buffer.GetLine( 2 ).Id; -+ -+ var moved = buffer.Move( new EditorAddressRange( 2, 2 ), 4 ); -+ -+ Assert.Equal( 4, moved.Start ); -+ Assert.Equal( identity, buffer.GetLine( moved.Start ).Id ); -+ -+ var copied = buffer.Copy( new EditorAddressRange( moved.Start, moved.End ), 0 ); -+ -+ Assert.NotEqual( identity, buffer.GetLine( copied.Start ).Id ); -+ Assert.Equal( 5, buffer.FindAddress( identity ) ); -+ Assert.Equal( new[] { "two", "one", "three", "four", "two" }, Text( buffer ) ); -+ } -+ -+ [Fact] -+ public void JoinRetainsTheFirstLineIdentity() { -+ var buffer = new EditorBuffer(); -+ buffer.Append( Lines( "ab", "cd", "ef" ) ); -+ var identity = buffer.GetLine( 1 ).Id; -+ -+ buffer.Join( new EditorAddressRange( 1, 3 ) ); -+ -+ Assert.Equal( identity, buffer.GetLine( 1 ).Id ); -+ Assert.Equal( new[] { "abcdef" }, Text( buffer ) ); -+ } -+ -+ [Fact] -+ public void LargeInsertDeleteMaintainsAddressOrderAcrossSegments() { -+ var buffer = new EditorBuffer(); -+ var lines = Enumerable.Range( 1, 5000 ) -+ .Select( value => new ReadOnlyMemory( Encoding.UTF8.GetBytes( value.ToString() ) ) ) -+ .ToArray(); -+ buffer.Append( lines ); -+ -+ buffer.Delete( new EditorAddressRange( 1001, 4000 ) ); -+ -+ Assert.Equal( 2000, buffer.Count ); -+ Assert.Equal( "1000", buffer.GetLine( 1000 ).GetText() ); -+ Assert.Equal( "4001", buffer.GetLine( 1001 ).GetText() ); -+ Assert.Equal( "5000", buffer.GetLine( 2000 ).GetText() ); -+ } -+ -+ private static IReadOnlyList> Lines( -+ params string[] values -+ ) => values.Select( value => new ReadOnlyMemory( Encoding.UTF8.GetBytes( value ) ) ).ToArray(); -+ -+ private static string[] Text( -+ EditorBuffer buffer -+ ) => buffer.GetLines().Select( line => line.GetText() ).ToArray(); -+} -diff --git a/tests/Ed.Shared.Tests/src/EditorEngineTests.cs b/tests/Ed.Shared.Tests/src/EditorEngineTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..29e30fd1e22ae4a903861e0a45b6c5d96ff1ee69 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/EditorEngineTests.cs -@@ -0,0 +1,279 @@ -+namespace Icod.LineEditor.Ed.Shared.Tests; -+ -+using System.Text; -+using Icod.CommandFramework.RegularExpressions; -+using Icod.LineEditor.Ed; -+ -+public sealed class EditorEngineTests { -+ [Fact] -+ public async Task ExecutesMutationAddressMarkCutAndUndoCommands() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "one", "two", "three" ) ); -+ var output = new MemoryStream(); -+ var error = new MemoryStream(); -+ var script = string.Join( -+ '\n', -+ "1ka", -+ "2d", -+ "1x", -+ "'ap", -+ "u", -+ "1,$p", -+ string.Empty -+ ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( script ), -+ output, -+ error -+ ); -+ -+ Assert.True( result.IsSuccess ); -+ Assert.Equal( "one\none\nthree\n", TextOf( output ) ); -+ Assert.Equal( new[] { "one", "three" }, BufferText( engine ) ); -+ } -+ -+ [Fact] -+ public async Task SemicolonRangeSearchUsesTheFirstAddressAsTheSearchOrigin() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "target", "middle", "target", "tail" ) ); -+ var output = new MemoryStream(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1;/target/p\n" ), -+ output, -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess, result.Diagnostic?.Message ); -+ Assert.Equal( "target\nmiddle\ntarget\n", TextOf( output ) ); -+ } -+ -+ [Fact] -+ public async Task SubstitutionUsesSharedBasicRegularExpressionsAndBackReferences() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "ab12 ab34", "nothing" ) ); -+ var output = new MemoryStream(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1s/\\([a-z][a-z]*\\)\\([0-9][0-9]*\\)/\\2-\\1/gp\n" ), -+ output, -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess ); -+ Assert.Equal( "12-ab 34-ab\n", TextOf( output ) ); -+ Assert.Equal( "12-ab 34-ab", engine.Buffer.GetLine( 1 ).GetText() ); -+ } -+ -+ [Fact] -+ public async Task CrLfScriptRecognizesSinglePeriodDataBlockTerminator() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "one" ) ); -+ var output = new MemoryStream(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1c\r\nONE\r\n.\r\n1p\r\n" ), -+ output, -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess, result.Diagnostic?.Message ); -+ Assert.Equal( "ONE\n", TextOf( output ) ); -+ Assert.Equal( new[] { "ONE" }, BufferText( engine ) ); -+ } -+ -+ [Fact] -+ public async Task GlobalCommandUsesStableSelectedLineIdentitiesDuringDeletion() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "keep", "drop one", "drop two", "keep again" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "g/drop/d\n1,$p\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess ); -+ Assert.Equal( new[] { "keep", "keep again" }, BufferText( engine ) ); -+ } -+ -+ [Fact] -+ public async Task FileReadWriteAndRememberedNameUseInjectedCapability() { -+ var files = new MemoryFileAccess(); -+ files.Files[ "input.txt" ] = new EditorFileReadResult( -+ Lines( "alpha", "beta" ), -+ true, -+ 11 -+ ); -+ var engine = CreateEngine( files: files ); -+ var output = new MemoryStream(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "e input.txt\n1s/alpha/ALPHA/\nw\n" ), -+ output, -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess ); -+ Assert.Equal( "input.txt", engine.RememberedFileName ); -+ Assert.Equal( new[] { "input.txt" }, files.ReadPaths ); -+ Assert.Equal( new[] { "input.txt" }, files.WrittenPaths ); -+ Assert.Equal( new[] { "ALPHA", "beta" }, files.LastWrittenLines.Select( line => Encoding.UTF8.GetString( line.Span ) ) ); -+ Assert.False( engine.IsModified ); -+ } -+ -+ [Fact] -+ public async Task RangeFilterReplacesLinesWithCapturedProcessOutput() { -+ var process = new MemoryProcessAccess { -+ Result = new EditorProcessResult( -+ 0, -+ false, -+ Encoding.UTF8.GetBytes( "ONE\nTWO\n" ), -+ ReadOnlyMemory.Empty -+ ) -+ }; -+ var engine = CreateEngine( process: process ); -+ engine.Load( Lines( "one", "two", "three" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1,2!upper\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess ); -+ Assert.Equal( "upper", process.LastCommand ); -+ Assert.Equal( "one\ntwo\n", Encoding.UTF8.GetString( process.LastInput.Span ) ); -+ Assert.Equal( new[] { "ONE", "TWO", "three" }, BufferText( engine ) ); -+ } -+ -+ [Fact] -+ public async Task EmptyCommandAtEndOfBufferReportsControlledAddressError() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "one" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1p\n\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.InvalidAddress, result.Diagnostic?.Code ); -+ } -+ -+ [Fact] -+ public async Task MoveDestinationInsideRangeReportsControlledAddressErrorAndPreservesState() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "one", "two", "three" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1,2m1\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.InvalidAddress, result.Diagnostic?.Code ); -+ Assert.Equal( new[] { "one", "two", "three" }, BufferText( engine ) ); -+ Assert.False( engine.IsModified ); -+ } -+ -+ [Fact] -+ public async Task OversizedSubstitutionOccurrenceReportsControlledCommandError() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "value" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1s/value/replacement/999999999999999999999999999999\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.InvalidCommand, result.Diagnostic?.Code ); -+ Assert.Equal( "value", engine.Buffer.GetLine( 1 ).GetText() ); -+ Assert.False( engine.IsModified ); -+ } -+ -+ [Fact] -+ public async Task DestinationAddressRejectsTrailingText() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "one", "two" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1m0unexpected\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.InvalidAddress, result.Diagnostic?.Code ); -+ Assert.Equal( new[] { "one", "two" }, BufferText( engine ) ); -+ Assert.False( engine.IsModified ); -+ } -+ -+ [Fact] -+ public async Task ProcessStartFailureReportsControlledProcessDiagnostic() { -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Standard, -+ new MemoryFileAccess(), -+ new FailingProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "!command\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.ProcessOperation, result.Diagnostic?.Code ); -+ } -+ -+ [Fact] -+ public async Task CancellationReturnsInterruptedStatusWithoutInventingAnError() { -+ var engine = CreateEngine(); -+ engine.Load( Lines( "one" ) ); -+ using var cancellation = new CancellationTokenSource(); -+ cancellation.Cancel(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1p\n" ), -+ new MemoryStream(), -+ new MemoryStream(), -+ cancellationToken: cancellation.Token -+ ); -+ -+ Assert.Equal( EditorExitStatus.Interrupted, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.Interrupted, result.Diagnostic?.Code ); -+ } -+ -+ private static EditorEngine CreateEngine( -+ MemoryFileAccess? files = null, -+ MemoryProcessAccess? process = null -+ ) => new( -+ EditorSecurityPolicy.Standard, -+ files ?? new MemoryFileAccess(), -+ process ?? new MemoryProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ -+ private static IReadOnlyList> Lines( -+ params string[] values -+ ) => values.Select( value => new ReadOnlyMemory( Encoding.UTF8.GetBytes( value ) ) ).ToArray(); -+ -+ private static MemoryStream StreamOf( -+ string value -+ ) => new( Encoding.UTF8.GetBytes( value ), writable: false ); -+ -+ private static string TextOf( -+ MemoryStream stream -+ ) => Encoding.UTF8.GetString( stream.ToArray() ); -+ -+ private static string[] BufferText( -+ EditorEngine engine -+ ) => engine.Buffer.GetLines().Select( line => line.GetText() ).ToArray(); -+} -diff --git a/tests/Ed.Shared.Tests/src/GlobalUsings.cs b/tests/Ed.Shared.Tests/src/GlobalUsings.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..c802f4480b198aad9bb8709a616c0891f99086c9 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/GlobalUsings.cs -@@ -0,0 +1 @@ -+global using Xunit; -diff --git a/tests/Ed.Shared.Tests/src/README.md b/tests/Ed.Shared.Tests/src/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..64c1e8d1df6d47e2a8268abdcc83918e0680a1e5 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/README.md -@@ -0,0 +1,7 @@ -+# Ed shared engine tests -+ -+- `EditorBufferTests.cs` validates scalable segmented storage and stable identities. -+- `EditorEngineTests.cs` validates editor state, commands, BRE substitutions, global execution, file effects, filters, undo, and cancellation. -+- `SecurityAndCompatibilityTests.cs` validates restricted profiles and Diffutils ed-script fixtures. -+- `TransactionalReplacementIntegrationTests.cs` validates Phase LE10 E6 overwrite, creation, metadata, rollback, cancellation, append, cleanup, and link behavior. -+- `TestCapabilities.cs` supplies deterministic in-memory file and process capabilities. -diff --git a/tests/Ed.Shared.Tests/src/SecurityAndCompatibilityTests.cs b/tests/Ed.Shared.Tests/src/SecurityAndCompatibilityTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..c43e83555f4ca6093e2e4f07db75b735d5e33cc6 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/SecurityAndCompatibilityTests.cs -@@ -0,0 +1,507 @@ -+namespace Icod.LineEditor.Ed.Shared.Tests; -+ -+using System.Diagnostics; -+using System.Text; -+using Icod.CommandFramework.RegularExpressions; -+using Icod.LineEditor.Ed; -+ -+public sealed class SecurityAndCompatibilityTests { -+ [Fact] -+ public async Task RestrictedPolicyRejectsShellBeforeInvokingCapabilityAndPreservesState() { -+ var process = new MemoryProcessAccess(); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Restricted( Directory.GetCurrentDirectory() ), -+ new MemoryFileAccess(), -+ process, -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ engine.Load( Lines( "one", "two" ) ); -+ var identities = engine.Buffer.GetLines().Select( line => line.Id ).ToArray(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "1,2!cat\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.RestrictedOperation, result.Diagnostic?.Code ); -+ Assert.Equal( 0, process.CallCount ); -+ Assert.Equal( identities, engine.Buffer.GetLines().Select( line => line.Id ) ); -+ Assert.False( engine.IsModified ); -+ } -+ -+ [Theory] -+ [InlineData( "../outside" )] -+ [InlineData( "/absolute" )] -+ [InlineData( "dir/file" )] -+ [InlineData( "dir\\file" )] -+ [InlineData( "C:relative" )] -+ [InlineData( "C:\\absolute" )] -+ [InlineData( "\\\\server\\share" )] -+ [InlineData( "\\\\?\\C:\\device" )] -+ [InlineData( "stream:name" )] -+ [InlineData( "CON" )] -+ [InlineData( "nul.txt" )] -+ [InlineData( "COM1.log" )] -+ [InlineData( "LPT9" )] -+ [InlineData( "leaf." )] -+ [InlineData( "leaf " )] -+ [InlineData( "!shell" )] -+ [InlineData( "." )] -+ [InlineData( ".." )] -+ public async Task RestrictedPolicyRejectsPathBearingFileCommands( -+ string path -+ ) { -+ var files = new MemoryFileAccess(); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Restricted( Directory.GetCurrentDirectory() ), -+ files, -+ new DeniedEditorProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ engine.Load( Lines( "one" ) ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( string.Concat( "w ", path, "\n" ) ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.RestrictedOperation, result.Diagnostic?.Code ); -+ Assert.Empty( files.WrittenPaths ); -+ Assert.Equal( "one", engine.Buffer.GetLine( 1 ).GetText() ); -+ } -+ -+ [Fact] -+ public async Task RestrictedGlobalShellDenialPreservesEditorStateAndPriorUndoUnit() { -+ var process = new MemoryProcessAccess(); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Restricted( Directory.GetCurrentDirectory() ), -+ new MemoryFileAccess(), -+ process, -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ engine.Load( Lines( "one", "two" ), rememberedFileName: "safe.txt" ); -+ var setup = await engine.ExecuteScriptAsync( -+ StreamOf( "2d\n1ka\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ Assert.True( setup.IsSuccess, setup.Diagnostic?.Message ); -+ var identities = engine.Buffer.GetLines().Select( line => line.Id ).ToArray(); -+ var address = engine.CurrentAddress; -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "g/one/! echo should-not-run\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.RestrictedOperation, result.Diagnostic?.Code ); -+ Assert.Equal( 0, process.CallCount ); -+ Assert.Equal( identities, engine.Buffer.GetLines().Select( line => line.Id ) ); -+ Assert.Equal( address, engine.CurrentAddress ); -+ Assert.True( engine.IsModified ); -+ Assert.Equal( "safe.txt", engine.RememberedFileName ); -+ -+ await using var markOutput = new MemoryStream(); -+ var markResult = await engine.ExecuteScriptAsync( -+ StreamOf( "'ap\n" ), -+ markOutput, -+ new MemoryStream() -+ ); -+ Assert.True( markResult.IsSuccess, markResult.Diagnostic?.Message ); -+ Assert.Equal( "one\n", Encoding.UTF8.GetString( markOutput.ToArray() ) ); -+ -+ var undo = await engine.ExecuteScriptAsync( -+ StreamOf( "u\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ Assert.True( undo.IsSuccess, undo.Diagnostic?.Message ); -+ Assert.Equal( new[] { "one", "two" }, engine.Buffer.GetLines().Select( line => line.GetText() ) ); -+ } -+ -+ [Fact] -+ public async Task RestrictedShellPreflightOccursBeforeAddressResolution() { -+ var process = new MemoryProcessAccess(); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Restricted( Directory.GetCurrentDirectory() ), -+ new MemoryFileAccess(), -+ process, -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ engine.Load( Lines( "one", "two" ), rememberedFileName: "safe.txt" ); -+ engine.SetCurrentAddress( 1 ); -+ var identities = engine.Buffer.GetLines().Select( line => line.Id ).ToArray(); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "'z! echo should-not-run\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.RestrictedOperation, result.Diagnostic?.Code ); -+ Assert.Equal( 0, process.CallCount ); -+ Assert.Equal( identities, engine.Buffer.GetLines().Select( line => line.Id ) ); -+ Assert.Equal( 1, engine.CurrentAddress ); -+ Assert.False( engine.IsModified ); -+ Assert.Equal( "safe.txt", engine.RememberedFileName ); -+ } -+ -+ [Fact] -+ public async Task RestrictedGlobalPathDenialOccursBeforeGlobalIteration() { -+ var files = new MemoryFileAccess(); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Restricted( Directory.GetCurrentDirectory() ), -+ files, -+ new DeniedEditorProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ engine.Load( Lines( "one", "two" ), rememberedFileName: "safe.txt" ); -+ engine.SetCurrentAddress( 1 ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "g/two/w ../outside\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.Equal( EditorExitStatus.Error, result.ExitStatus ); -+ Assert.Equal( EditorDiagnosticCode.RestrictedOperation, result.Diagnostic?.Code ); -+ Assert.Equal( 1, engine.CurrentAddress ); -+ Assert.False( engine.IsModified ); -+ Assert.Equal( "safe.txt", engine.RememberedFileName ); -+ Assert.Empty( files.WrittenPaths ); -+ } -+ -+ [Fact] -+ public async Task RestrictedEngineAllowsSimpleNamesAndReusesTheRememberedLogicalName() { -+ var files = new MemoryFileAccess(); -+ files.Files[ "input.txt" ] = new EditorFileReadResult( Lines( "value" ), true, 6 ); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Restricted( Directory.GetCurrentDirectory() ), -+ files, -+ new DeniedEditorProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "e input.txt\nw\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess, result.Diagnostic?.Message ); -+ Assert.Equal( "input.txt", engine.RememberedFileName ); -+ Assert.Equal( new[] { "input.txt" }, files.ReadPaths ); -+ Assert.Equal( new[] { "input.txt" }, files.WrittenPaths ); -+ } -+ -+ [Fact] -+ public async Task RestrictedFactoryConstrainsInjectedFileCapabilityToCapturedDirectory() { -+ var files = new MemoryFileAccess(); -+ var directory = System.IO.Path.GetFullPath( System.IO.Path.Combine( System.IO.Path.GetTempPath(), Guid.NewGuid().ToString( "N" ) ) ); -+ Directory.CreateDirectory( directory ); -+ try { -+ var expected = System.IO.Path.Combine( directory, "input.txt" ); -+ files.Files[ expected ] = new EditorFileReadResult( Lines( "value" ), true, 6 ); -+ var engine = EditorEngine.CreateRestricted( directory, files ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ StreamOf( "e input.txt\n" ), -+ new MemoryStream(), -+ new MemoryStream() -+ ); -+ -+ Assert.True( result.IsSuccess, result.Diagnostic?.Message ); -+ Assert.Equal( expected, Assert.Single( files.ReadPaths ) ); -+ Assert.Equal( "input.txt", engine.RememberedFileName ); -+ } finally { -+ Directory.Delete( directory, recursive: true ); -+ } -+ } -+ -+ [Fact] -+ public async Task RestrictedFileCapabilityMapsSimpleNamesIntoCapturedDirectory() { -+ var inner = new MemoryFileAccess(); -+ var directory = System.IO.Path.GetFullPath( System.IO.Path.Combine( System.IO.Path.GetTempPath(), Guid.NewGuid().ToString( "N" ) ) ); -+ Directory.CreateDirectory( directory ); -+ try { -+ var expected = System.IO.Path.Combine( directory, "file.txt" ); -+ inner.Files[ expected ] = new EditorFileReadResult( Lines( "value" ), true, 6 ); -+ var restricted = new RestrictedEditorFileAccess( directory, inner ); -+ -+ var result = await restricted.ReadAsync( "file.txt" ); -+ -+ Assert.Equal( expected, Assert.Single( inner.ReadPaths ) ); -+ Assert.Equal( "value", Encoding.UTF8.GetString( Assert.Single( result.Lines ).Span ) ); -+ } finally { -+ Directory.Delete( directory, recursive: true ); -+ } -+ } -+ -+ [Fact] -+ public async Task RestrictedCapabilityCapturesDirectoryAndStatesItsPathnameOnlyBoundary() { -+ var inner = new MemoryFileAccess(); -+ var directory = System.IO.Path.GetFullPath( System.IO.Path.Combine( System.IO.Path.GetTempPath(), Guid.NewGuid().ToString( "N" ) ) ); -+ var expected = System.IO.Path.Combine( directory, "leaf.txt" ); -+ inner.Files[ expected ] = new EditorFileReadResult( Lines( "value" ), true, 6 ); -+ var restricted = new RestrictedEditorFileAccess( directory, inner ); -+ -+ var result = await restricted.ReadAsync( "leaf.txt" ); -+ -+ Assert.Equal( directory, restricted.WorkingDirectory ); -+ Assert.False( restricted.ProvidesPhysicalConfinement ); -+ Assert.Equal( expected, Assert.Single( inner.ReadPaths ) ); -+ Assert.Equal( "value", Encoding.UTF8.GetString( Assert.Single( result.Lines ).Span ) ); -+ } -+ -+ [Theory] -+ [InlineData( "leaf.txt", true )] -+ [InlineData( "dir/file", false )] -+ [InlineData( "dir\\file", false )] -+ [InlineData( "C:relative", false )] -+ [InlineData( "C:\\absolute", false )] -+ [InlineData( "\\\\server\\share", false )] -+ [InlineData( "stream:name", false )] -+ [InlineData( "CON", false )] -+ [InlineData( "nul.txt", false )] -+ [InlineData( "COM1.log", false )] -+ [InlineData( "LPT9", false )] -+ [InlineData( "COM10", true )] -+ [InlineData( "leaf.", false )] -+ [InlineData( "leaf ", false )] -+ [InlineData( "!shell", false )] -+ public void RestrictedPathClassificationIsHostIndependent( -+ string candidate, -+ bool expected -+ ) => Assert.Equal( expected, EditorRestrictedPath.IsSimpleFileName( candidate ) ); -+ -+ [Fact] -+ public async Task RestrictedPathnamePolicyCharacterizesLinkAndReparseBehaviorWhenSupported() { -+ var root = System.IO.Path.Combine( System.IO.Path.GetTempPath(), string.Concat( ".icod-red-links-", Guid.NewGuid().ToString( "N" ) ) ); -+ var outside = System.IO.Path.Combine( System.IO.Path.GetTempPath(), string.Concat( ".icod-red-targets-", Guid.NewGuid().ToString( "N" ) ) ); -+ Directory.CreateDirectory( root ); -+ Directory.CreateDirectory( outside ); -+ var target = System.IO.Path.Combine( outside, "target.txt" ); -+ var symbolicLeaf = System.IO.Path.Combine( root, "symbolic.txt" ); -+ var hardLeaf = System.IO.Path.Combine( root, "hard.txt" ); -+ await File.WriteAllTextAsync( target, "outside-through-link\n" ); -+ var restricted = new RestrictedEditorFileAccess( root, new StandardEditorFileAccess() ); -+ var exercised = 0; -+ try { -+ if ( TryCreateSymbolicLink( symbolicLeaf, target ) ) { -+ var read = await restricted.ReadAsync( "symbolic.txt" ); -+ Assert.Equal( "outside-through-link", Encoding.UTF8.GetString( Assert.Single( read.Lines ).Span ) ); -+ exercised++; -+ } -+ if ( TryCreateHardLink( hardLeaf, target ) ) { -+ var read = await restricted.ReadAsync( "hard.txt" ); -+ Assert.Equal( "outside-through-link", Encoding.UTF8.GetString( Assert.Single( read.Lines ).Span ) ); -+ exercised++; -+ } -+ Assert.False( restricted.ProvidesPhysicalConfinement ); -+ Assert.InRange( exercised, 0, 2 ); -+ } finally { -+ DeleteFileIfPresent( symbolicLeaf ); -+ DeleteFileIfPresent( hardLeaf ); -+ DeleteFileIfPresent( target ); -+ DeleteDirectoryIfPresent( root ); -+ DeleteDirectoryIfPresent( outside ); -+ } -+ } -+ -+ [Fact] -+ public async Task RestrictedPathnamePolicyLeavesValidationOpenRacesToUnderlyingCapabilityWhenSupported() { -+ var root = System.IO.Path.Combine( System.IO.Path.GetTempPath(), string.Concat( ".icod-red-race-", Guid.NewGuid().ToString( "N" ) ) ); -+ var outside = System.IO.Path.Combine( System.IO.Path.GetTempPath(), string.Concat( ".icod-red-race-targets-", Guid.NewGuid().ToString( "N" ) ) ); -+ Directory.CreateDirectory( root ); -+ Directory.CreateDirectory( outside ); -+ var first = System.IO.Path.Combine( outside, "first.txt" ); -+ var second = System.IO.Path.Combine( outside, "second.txt" ); -+ var leaf = System.IO.Path.Combine( root, "alias.txt" ); -+ await File.WriteAllTextAsync( first, "first\n" ); -+ await File.WriteAllTextAsync( second, "second\n" ); -+ try { -+ if ( !TryCreateSymbolicLink( leaf, first ) ) { -+ return; -+ } -+ var swapping = new SwappingLinkFileAccess( leaf, second ); -+ var restricted = new RestrictedEditorFileAccess( root, swapping ); -+ -+ var read = await restricted.ReadAsync( "alias.txt" ); -+ -+ Assert.True( swapping.Swapped ); -+ Assert.Equal( "second", Encoding.UTF8.GetString( Assert.Single( read.Lines ).Span ) ); -+ Assert.False( restricted.ProvidesPhysicalConfinement ); -+ } finally { -+ DeleteFileIfPresent( leaf ); -+ DeleteFileIfPresent( first ); -+ DeleteFileIfPresent( second ); -+ DeleteDirectoryIfPresent( root ); -+ DeleteDirectoryIfPresent( outside ); -+ } -+ } -+ -+ [Theory] -+ [InlineData( "gnu-diffutils" )] -+ [InlineData( "icod-diffutils" )] -+ public async Task AppliesDiffutilsEdScriptCompatibilityFixture( -+ string fixtureName -+ ) { -+ var root = System.IO.Path.Combine( AppContext.BaseDirectory, "fixtures", fixtureName ); -+ var original = await ReadLfLinesAsync( System.IO.Path.Combine( root, "original.txt" ) ); -+ var expected = await ReadLfLinesAsync( System.IO.Path.Combine( root, "expected.txt" ) ); -+ await using var script = File.OpenRead( System.IO.Path.Combine( root, "change.ed" ) ); -+ var engine = new EditorEngine( -+ EditorSecurityPolicy.Standard, -+ new MemoryFileAccess(), -+ new MemoryProcessAccess(), -+ GnuBasicRegularExpressionProvider.Default -+ ); -+ engine.Load( original ); -+ -+ var result = await engine.ExecuteScriptAsync( -+ script, -+ new MemoryStream(), -+ new MemoryStream(), -+ System.IO.Path.Combine( fixtureName, "change.ed" ) -+ ); -+ -+ Assert.True( result.IsSuccess, result.Diagnostic?.Message ); -+ Assert.Equal( -+ expected.Select( line => Encoding.UTF8.GetString( line.Span ) ), -+ engine.Buffer.GetLines().Select( line => line.GetText() ) -+ ); -+ } -+ -+ private static async Task>> ReadLfLinesAsync( -+ string path -+ ) { -+ var lines = await File.ReadAllLinesAsync( path ); -+ return lines -+ .Where( value => 0 != value.Length ) -+ .Select( value => new ReadOnlyMemory( Encoding.UTF8.GetBytes( value ) ) ) -+ .ToArray(); -+ } -+ -+ private static bool TryCreateSymbolicLink( -+ string linkPath, -+ string targetPath -+ ) { -+ try { -+ File.CreateSymbolicLink( linkPath, targetPath ); -+ return true; -+ } catch ( Exception exception ) when ( -+ exception is IOException -+ or UnauthorizedAccessException -+ or NotSupportedException -+ ) { -+ return false; -+ } -+ } -+ -+ private static bool TryCreateHardLink( -+ string linkPath, -+ string targetPath -+ ) { -+ try { -+ var startInfo = new ProcessStartInfo { -+ FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/ln", -+ UseShellExecute = false, -+ CreateNoWindow = true -+ }; -+ if ( OperatingSystem.IsWindows() ) { -+ startInfo.ArgumentList.Add( "/d" ); -+ startInfo.ArgumentList.Add( "/s" ); -+ startInfo.ArgumentList.Add( "/c" ); -+ startInfo.ArgumentList.Add( -+ string.Concat( "mklink /H \"", linkPath, "\" \"", targetPath, "\"" ) -+ ); -+ } else { -+ startInfo.ArgumentList.Add( targetPath ); -+ startInfo.ArgumentList.Add( linkPath ); -+ } -+ using var process = Process.Start( startInfo ); -+ if ( null == process ) { -+ return false; -+ } -+ process.WaitForExit(); -+ return 0 == process.ExitCode && File.Exists( linkPath ); -+ } catch ( Exception exception ) when ( -+ exception is IOException -+ or UnauthorizedAccessException -+ or System.ComponentModel.Win32Exception -+ or InvalidOperationException -+ ) { -+ return false; -+ } -+ } -+ -+ private static void DeleteFileIfPresent( -+ string path -+ ) { -+ try { -+ File.Delete( path ); -+ } catch ( IOException ) { -+ } catch ( UnauthorizedAccessException ) { -+ } -+ } -+ -+ private static void DeleteDirectoryIfPresent( -+ string path -+ ) { -+ try { -+ Directory.Delete( path, recursive: true ); -+ } catch ( IOException ) { -+ } catch ( UnauthorizedAccessException ) { -+ } -+ } -+ -+ private sealed class SwappingLinkFileAccess : IEditorFileAccess { -+ private readonly string linkPath; -+ private readonly string replacementTarget; -+ private readonly StandardEditorFileAccess inner = new(); -+ -+ public SwappingLinkFileAccess( -+ string linkPath, -+ string replacementTarget -+ ) { -+ this.linkPath = linkPath; -+ this.replacementTarget = replacementTarget; -+ } -+ -+ public bool Swapped { get; private set; } -+ -+ public ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ File.Delete( this.linkPath ); -+ File.CreateSymbolicLink( this.linkPath, this.replacementTarget ); -+ this.Swapped = true; -+ return this.inner.ReadAsync( path, cancellationToken ); -+ } -+ -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) => this.inner.WriteAsync( path, lines, append, terminateFinalRecord, cancellationToken ); -+ } -+ -+ private static IReadOnlyList> Lines( -+ params string[] values -+ ) => values.Select( value => new ReadOnlyMemory( Encoding.UTF8.GetBytes( value ) ) ).ToArray(); -+ -+ private static MemoryStream StreamOf( -+ string value -+ ) => new( Encoding.UTF8.GetBytes( value ), writable: false ); -+} -diff --git a/tests/Ed.Shared.Tests/src/TestCapabilities.cs b/tests/Ed.Shared.Tests/src/TestCapabilities.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..486790c7ed86a452eb99e9d4d0331235295f1638 ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/TestCapabilities.cs -@@ -0,0 +1,109 @@ -+namespace Icod.LineEditor.Ed.Shared.Tests; -+ -+using Icod.LineEditor.Ed; -+ -+/// Provides deterministic in-memory file effects for engine tests. -+internal sealed class MemoryFileAccess : IEditorFileAccess { -+ /// Gets the configured readable files by logical path. -+ internal Dictionary Files { -+ get; -+ } = new( StringComparer.Ordinal ); -+ -+ /// Gets the paths requested for reading. -+ internal List ReadPaths { -+ get; -+ } = new(); -+ -+ /// Gets the paths requested for writing. -+ internal List WrittenPaths { -+ get; -+ } = new(); -+ -+ /// Gets the lines supplied to the most recent write. -+ internal IReadOnlyList> LastWrittenLines { -+ get; -+ private set; -+ } = Array.Empty>(); -+ -+ /// -+ public ValueTask ReadAsync( -+ string path, -+ CancellationToken cancellationToken = default -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.ReadPaths.Add( path ); -+ if ( !this.Files.TryGetValue( path, out var value ) ) { -+ throw new FileNotFoundException( path ); -+ } -+ return ValueTask.FromResult( value ); -+ } -+ -+ /// -+ public ValueTask WriteAsync( -+ string path, -+ IReadOnlyList> lines, -+ bool append, -+ bool terminateFinalRecord, -+ CancellationToken cancellationToken = default -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.WrittenPaths.Add( path ); -+ this.LastWrittenLines = lines.Select( line => new ReadOnlyMemory( line.ToArray() ) ).ToArray(); -+ var bytes = lines.Sum( line => (long)line.Length ) -+ + Math.Max( 0, lines.Count - ( terminateFinalRecord ? 0 : 1 ) ); -+ return ValueTask.FromResult( new EditorFileWriteResult( bytes ) ); -+ } -+} -+ -+/// Provides deterministic in-memory process effects for engine tests. -+internal sealed class MemoryProcessAccess : IEditorProcessAccess { -+ /// Gets the number of process invocations. -+ internal int CallCount { -+ get; -+ private set; -+ } -+ -+ /// Gets the most recently requested shell command. -+ internal string? LastCommand { -+ get; -+ private set; -+ } -+ -+ /// Gets the standard input supplied to the most recent process. -+ internal ReadOnlyMemory LastInput { -+ get; -+ private set; -+ } -+ -+ /// Gets or sets the deterministic process result. -+ internal EditorProcessResult Result { -+ get; -+ set; -+ } = new( 0, false, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty ); -+ -+ /// -+ public ValueTask RunShellAsync( -+ string command, -+ ReadOnlyMemory standardInput, -+ CancellationToken cancellationToken = default -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.CallCount++; -+ this.LastCommand = command; -+ this.LastInput = standardInput.ToArray(); -+ return ValueTask.FromResult( this.Result ); -+ } -+} -+ -+/// Throws a deterministic process-start failure for diagnostic tests. -+internal sealed class FailingProcessAccess : IEditorProcessAccess { -+ /// -+ public ValueTask RunShellAsync( -+ string command, -+ ReadOnlyMemory standardInput, -+ CancellationToken cancellationToken = default -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ throw new InvalidOperationException( "The process could not be started." ); -+ } -+} -diff --git a/tests/Ed.Shared.Tests/src/TransactionalReplacementIntegrationTests.cs b/tests/Ed.Shared.Tests/src/TransactionalReplacementIntegrationTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..eede88f87e8f1e1f35b9995070ec71453cba7fff ---- /dev/null -+++ b/tests/Ed.Shared.Tests/src/TransactionalReplacementIntegrationTests.cs -@@ -0,0 +1,249 @@ -+namespace Icod.LineEditor.Ed.Shared.Tests; -+ -+using System.Text; -+using Icod.CommandFramework.FileSystem; -+using Icod.CommandFramework.FileSystem.TransactionalReplacement; -+using Icod.LineEditor.Ed; -+using Xunit; -+ -+/// Validates the Phase LE10 integration between Ed writes and Completion Gate E6. -+public sealed class TransactionalReplacementIntegrationTests { -+ /// Verifies that replacement preserves representable source metadata. -+ [Fact] -+ public async Task OverwriteUsesTransactionAndPreservesMetadata() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "buffer.txt" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ UnixFileMode? originalMode = null; -+ if ( !OperatingSystem.IsWindows() ) { -+ originalMode = UnixFileMode.UserRead -+ | UnixFileMode.UserWrite -+ | UnixFileMode.GroupRead; -+ File.SetUnixFileMode( path, originalMode.Value ); -+ } -+ -+ var injector = new RecordingFailureInjector(); -+ var access = new StandardEditorFileAccess( -+ SystemTransactionalReplacementFileSystem.Instance, -+ SystemFileSystemOperations.Instance, -+ injector -+ ); -+ var result = await access.WriteAsync( -+ path, -+ Lines( "replacement" ), -+ append: false, -+ terminateFinalRecord: true -+ ); -+ -+ Assert.Equal( (long)Encoding.UTF8.GetByteCount( "replacement\n" ), result.ByteCount ); -+ Assert.Equal( "replacement\n", await File.ReadAllTextAsync( path ) ); -+ if ( originalMode.HasValue ) { -+#pragma warning disable CA1416 -+ Assert.Equal( originalMode.Value, File.GetUnixFileMode( path ) ); -+#pragma warning restore CA1416 -+ } -+ Assert.Equal( new string[] { "buffer.txt" }, EntryNames( directory.Path ) ); -+ Assert.Contains( TransactionalReplacementStage.WriteTemporary, injector.ObservedStages ); -+ Assert.Contains( TransactionalReplacementStage.FlushTemporary, injector.ObservedStages ); -+ Assert.Contains( TransactionalReplacementStage.Commit, injector.ObservedStages ); -+ } -+ -+ /// Verifies that an absent Ed destination is published through the E6 transaction. -+ [Fact] -+ public async Task WriteCreatesAbsentDestinationTransactionally() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "new.txt" ); -+ var injector = new RecordingFailureInjector(); -+ var access = new StandardEditorFileAccess( -+ SystemTransactionalReplacementFileSystem.Instance, -+ SystemFileSystemOperations.Instance, -+ injector -+ ); -+ -+ var result = await access.WriteAsync( -+ path, -+ Lines( "new" ), -+ append: false, -+ terminateFinalRecord: true -+ ); -+ -+ Assert.Equal( 4L, result.ByteCount ); -+ Assert.Equal( "new\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Contains( TransactionalReplacementStage.Commit, injector.ObservedStages ); -+ Assert.Equal( new string[] { "new.txt" }, EntryNames( directory.Path ) ); -+ } -+ -+ /// Verifies rollback after a failure injected after destination publication. -+ [Fact] -+ public async Task PostCommitFailureRollsBackAndCleansTransactionArtifacts() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "buffer.txt" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ var injector = new ThrowAtStageFailureInjector( -+ TransactionalReplacementStage.ApplyMetadata -+ ); -+ var access = new StandardEditorFileAccess( -+ SystemTransactionalReplacementFileSystem.Instance, -+ SystemFileSystemOperations.Instance, -+ injector -+ ); -+ -+ await Assert.ThrowsAsync( -+ () => access.WriteAsync( -+ path, -+ Lines( "replacement" ), -+ append: false, -+ terminateFinalRecord: true -+ ).AsTask() -+ ); -+ -+ Assert.Equal( "original\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Contains( TransactionalReplacementStage.ApplyMetadata, injector.ObservedStages ); -+ Assert.Equal( new string[] { "buffer.txt" }, EntryNames( directory.Path ) ); -+ } -+ -+ /// Verifies that cancellation leaves the observed destination unchanged. -+ [Fact] -+ public async Task CanceledOverwritePreservesOriginalAndCleansArtifacts() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "buffer.txt" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ using var cancellation = new CancellationTokenSource(); -+ cancellation.Cancel(); -+ var access = new StandardEditorFileAccess(); -+ -+ await Assert.ThrowsAnyAsync( -+ () => access.WriteAsync( -+ path, -+ Lines( "replacement" ), -+ append: false, -+ terminateFinalRecord: true, -+ cancellation.Token -+ ).AsTask() -+ ); -+ -+ Assert.Equal( "original\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Equal( new string[] { "buffer.txt" }, EntryNames( directory.Path ) ); -+ } -+ -+ /// Verifies that Ed append remains a direct append policy rather than replacement. -+ [Fact] -+ public async Task AppendBypassesTransactionalReplacement() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "buffer.txt" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ var injector = new ThrowAtStageFailureInjector( -+ TransactionalReplacementStage.Validate -+ ); -+ var access = new StandardEditorFileAccess( -+ SystemTransactionalReplacementFileSystem.Instance, -+ SystemFileSystemOperations.Instance, -+ injector -+ ); -+ -+ await access.WriteAsync( -+ path, -+ Lines( "appended" ), -+ append: true, -+ terminateFinalRecord: true -+ ); -+ -+ Assert.Equal( "original\nappended\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Empty( injector.ObservedStages ); -+ } -+ -+ /// Verifies that Ed resolves terminal symbolic links before no-follow E6 planning. -+ [Fact] -+ public async Task TransactionalOverwriteFollowsTerminalSymbolicLink() { -+ using var directory = new TemporaryDirectory(); -+ var target = System.IO.Path.Combine( directory.Path, "target.txt" ); -+ var link = System.IO.Path.Combine( directory.Path, "link.txt" ); -+ await File.WriteAllTextAsync( target, "target\n" ); -+ try { -+ File.CreateSymbolicLink( link, target ); -+ } catch ( Exception ex ) when ( -+ ex is UnauthorizedAccessException -+ or PlatformNotSupportedException -+ or IOException -+ ) { -+ return; -+ } -+ var access = new StandardEditorFileAccess(); -+ -+ await access.WriteAsync( -+ link, -+ Lines( "replacement" ), -+ append: false, -+ terminateFinalRecord: true -+ ); -+ -+ Assert.Equal( "replacement\n", await File.ReadAllTextAsync( target ) ); -+ Assert.NotNull( new FileInfo( link ).LinkTarget ); -+ } -+ -+ private static IReadOnlyList> Lines( -+ params string[] values -+ ) => values.Select( -+ value => new ReadOnlyMemory( Encoding.UTF8.GetBytes( value ) ) -+ ).ToArray(); -+ -+ private static string[] EntryNames( -+ string directory -+ ) => Directory.EnumerateFileSystemEntries( directory ) -+ .Select( value => System.IO.Path.GetFileName( value ) ?? string.Empty ) -+ .OrderBy( value => value, StringComparer.Ordinal ) -+ .ToArray(); -+ -+ private class RecordingFailureInjector : ITransactionalReplacementFailureInjector { -+ public List ObservedStages { get; } = new(); -+ -+ public virtual ValueTask OnStageAsync( -+ TransactionalReplacementStage stage, -+ TransactionalReplacementArtifact artifact, -+ CancellationToken cancellationToken = default -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.ObservedStages.Add( stage ); -+ return ValueTask.CompletedTask; -+ } -+ } -+ -+ private sealed class ThrowAtStageFailureInjector : RecordingFailureInjector { -+ private readonly TransactionalReplacementStage failureStage; -+ -+ public ThrowAtStageFailureInjector( -+ TransactionalReplacementStage failureStage -+ ) { -+ this.failureStage = failureStage; -+ } -+ -+ public override async ValueTask OnStageAsync( -+ TransactionalReplacementStage stage, -+ TransactionalReplacementArtifact artifact, -+ CancellationToken cancellationToken = default -+ ) { -+ await base.OnStageAsync( stage, artifact, cancellationToken ).ConfigureAwait( false ); -+ if ( this.failureStage == stage ) { -+ throw new IOException( $"Injected failure at {stage}." ); -+ } -+ } -+ } -+ -+ private sealed class TemporaryDirectory : IDisposable { -+ public TemporaryDirectory() { -+ this.Path = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $".icod-ed-le10-{Guid.NewGuid():N}" -+ ); -+ Directory.CreateDirectory( this.Path ); -+ } -+ -+ public string Path { get; } -+ -+ public void Dispose() { -+ if ( Directory.Exists( this.Path ) ) { -+ Directory.Delete( this.Path, recursive: true ); -+ } -+ } -+ } -+} -diff --git a/tests/Ed.Tests/Icod.LineEditor.Ed.Tests.csproj b/tests/Ed.Tests/Icod.LineEditor.Ed.Tests.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..7b484c5de9e105e0032e3bbdc79b35a2ce777046 ---- /dev/null -+++ b/tests/Ed.Tests/Icod.LineEditor.Ed.Tests.csproj -@@ -0,0 +1,57 @@ -+ -+ -+ -+ net10.0 -+ 13.0 -+ enable -+ enable -+ false -+ true -+ Icod.LineEditor.Ed.Tests -+ Icod.LineEditor.Ed.Tests -+ -+ -+ -+ -+ -+ runtime; build; native; contentfiles; analyzers; buildtransitive -+ all -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/tests/Ed.Tests/src/CommandTests.cs b/tests/Ed.Tests/src/CommandTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..e33eaf79ba3b3cb574bd4339f3e3fa5cd380c7b6 ---- /dev/null -+++ b/tests/Ed.Tests/src/CommandTests.cs -@@ -0,0 +1,448 @@ -+namespace Icod.LineEditor.Ed.Tests; -+ -+using System.Text; -+using Icod.CommandFramework.Diagnostics; -+using Icod.LineEditor.Ed; -+using Xunit; -+ -+/// Exercises the Phase LE7 command boundary over the reusable editor engine. -+public sealed class CommandTests { -+ /// Verifies the public help and version surfaces. -+ [Theory] -+ [InlineData( "--help", "Usage: ed" )] -+ [InlineData( "--version", "GNU ed 1.22.5" )] -+ public async Task ReportsHelpAndVersion( -+ string option, -+ string expected -+ ) { -+ var result = await RunAsync( string.Empty, option ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Contains( expected, result.StandardOutput, StringComparison.Ordinal ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ /// Verifies append, address ranges, printing, and forced quit through the shared engine. -+ [Fact] -+ public async Task ExecutesStandardProfileScript() { -+ var result = await RunAsync( -+ "a\nalpha\nbeta\n.\n1,2p\nQ\n" -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "alpha\nbeta\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ /// Verifies that the GNU traditional-mode option is accepted at the command boundary. -+ [Fact] -+ public async Task AcceptsTraditionalCompatibilityMode() { -+ var result = await RunAsync( -+ "a\nalpha\n.\np\nQ\n", -+ "--traditional" -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "alpha\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ /// Verifies that -E selects the Shared GNU extended-expression provider. -+ [Fact] -+ public async Task UsesExtendedRegularExpressions() { -+ var result = await RunAsync( -+ "a\nalpha\n.\ns/(alpha|beta)/X/\np\nQ\n", -+ "-E" -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "X\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ /// Verifies a missing initial file remains editable but produces the GNU input-file status. -+ [Fact] -+ public async Task MissingInitialFileReturnsStatusTwo() { -+ var path = CreateTemporaryPath(); -+ var result = await RunAsync( -+ "Q\n", -+ "-s", -+ path -+ ); -+ -+ Assert.Equal( 2, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Contains( "No such file or directory", result.StandardError, StringComparison.Ordinal ); -+ } -+ -+ /// Verifies GNU +line initial-address selection without emitting byte counts. -+ [Fact] -+ public async Task SelectsInitialAddress() { -+ var path = CreateTemporaryPath(); -+ try { -+ await File.WriteAllTextAsync( path, "one\ntwo\nthree\n" ); -+ var result = await RunAsync( -+ "p\nQ\n", -+ "-s", -+ "+2", -+ path -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "two\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } finally { -+ DeleteIfPresent( path ); -+ } -+ } -+ -+ /// Verifies GNU behavior that an oversized +line selects the last line. -+ [Fact] -+ public async Task OversizedInitialAddressSelectsLastLine() { -+ var path = CreateTemporaryPath(); -+ try { -+ await File.WriteAllTextAsync( path, "one\ntwo\nthree\n" ); -+ var result = await RunAsync( -+ "p\nQ\n", -+ "-s", -+ "+999", -+ path -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "three\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } finally { -+ DeleteIfPresent( path ); -+ } -+ } -+ -+ /// Verifies that script mode suppresses initial-read and write byte counts. -+ [Fact] -+ public async Task ScriptModeSuppressesByteCounts() { -+ var inputPath = CreateTemporaryPath(); -+ var outputPath = CreateTemporaryPath(); -+ try { -+ await File.WriteAllTextAsync( inputPath, "one\ntwo\n" ); -+ var result = await RunAsync( -+ string.Concat( "w ", outputPath, "\nQ\n" ), -+ "-s", -+ inputPath -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ Assert.Equal( "one\ntwo\n", await File.ReadAllTextAsync( outputPath ) ); -+ } finally { -+ DeleteIfPresent( inputPath ); -+ DeleteIfPresent( outputPath ); -+ } -+ } -+ -+ /// Verifies modified-buffer refusal maps to the GNU input/buffer problem status. -+ [Fact] -+ public async Task ModifiedBufferRefusalReturnsStatusTwo() { -+ var result = await RunAsync( -+ "a\nalpha\n.\nq\n" -+ ); -+ -+ Assert.Equal( 2, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( "?\n", result.StandardError ); -+ } -+ -+ /// Verifies that the restricted profile denies shell dispatch before process creation. -+ [Fact] -+ public async Task RestrictedModeDeniesShellCommands() { -+ var result = await RunAsync( -+ "! echo should-not-run\n", -+ "--restricted" -+ ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( "?\n", result.StandardError ); -+ } -+ -+ /// Verifies verbose diagnostic expansion while retaining the leading question mark. -+ [Fact] -+ public async Task VerboseModeExplainsControlledErrors() { -+ var result = await RunAsync( -+ "Z\n", -+ "--verbose" -+ ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.StartsWith( "?\n", result.StandardError ); -+ Assert.Contains( "Unknown command", result.StandardError, StringComparison.Ordinal ); -+ } -+ -+ /// Verifies that quiet mode suppresses diagnostics without changing failure status. -+ [Fact] -+ public async Task QuietModeSuppressesDiagnostics() { -+ var result = await RunAsync( -+ "Z\n", -+ "--quiet" -+ ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ /// Verifies that script mode does not suppress diagnostics. -+ [Fact] -+ public async Task ScriptModeRetainsDiagnostics() { -+ var result = await RunAsync( -+ "Z\n", -+ "--script" -+ ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( "?\n", result.StandardError ); -+ } -+ -+ /// Verifies loose exit status continues through a failed command. -+ [Fact] -+ public async Task LooseExitStatusContinuesAfterCommandFailure() { -+ var result = await RunAsync( -+ "Z\na\nalpha\n.\np\nQ\n", -+ "--loose-exit-status" -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "alpha\n", result.StandardOutput ); -+ Assert.Equal( "?\n", result.StandardError ); -+ } -+ -+ /// Verifies the P command toggles the default prompt for subsequent commands. -+ [Fact] -+ public async Task PromptCommandTogglesPrompting() { -+ var result = await RunAsync( -+ "P\nQ\n" -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "*", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ /// Verifies an explicit prompt is emitted for scripted input. -+ [Fact] -+ public async Task ExplicitPromptTurnsPromptingOn() { -+ var result = await RunAsync( -+ "Q\n", -+ "--prompt", -+ "ed> " -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "ed> ", result.StandardOutput ); -+ } -+ -+ /// Verifies explicit CR stripping for CRLF-oriented input files. -+ [Fact] -+ public async Task StripsTrailingCarriageReturns() { -+ var path = CreateTemporaryPath(); -+ try { -+ await File.WriteAllBytesAsync( path, Encoding.UTF8.GetBytes( "alpha\r\nbeta\r\n" ) ); -+ var result = await RunAsync( -+ "1,2p\nQ\n", -+ "-s", -+ "--strip-trailing-cr", -+ path -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "alpha\nbeta\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } finally { -+ DeleteIfPresent( path ); -+ } -+ } -+ -+ /// Verifies that strip-trailing-cr preserves CR on an unterminated final record. -+ [Fact] -+ public async Task PreservesTrailingCarriageReturnOnUnterminatedRecord() { -+ var path = CreateTemporaryPath(); -+ try { -+ await File.WriteAllBytesAsync( path, Encoding.UTF8.GetBytes( "alpha\r" ) ); -+ var result = await RunAsync( -+ "1p\nQ\n", -+ "-s", -+ "--strip-trailing-cr", -+ path -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "alpha\r\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } finally { -+ DeleteIfPresent( path ); -+ } -+ } -+ -+ /// Verifies deterministic cancellation status before command input is consumed. -+ [Fact] -+ public async Task CancellationReturnsInterruptedStatus() { -+ using var cancellationSource = new CancellationTokenSource(); -+ cancellationSource.Cancel(); -+ await using var input = new MemoryStream( Encoding.UTF8.GetBytes( "p\n" ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ -+ var status = await Command.RunAsync( -+ [], -+ input, -+ output, -+ error, -+ cancellationSource.Token -+ ); -+ -+ Assert.Equal( 2, status ); -+ } -+ -+ /// Verifies that a broken output stream becomes a controlled command failure. -+ [Fact] -+ public async Task BrokenOutputReturnsFailure() { -+ await using var input = new MemoryStream( -+ Encoding.UTF8.GetBytes( "a\nalpha\n.\np\nQ\n" ), -+ writable: false -+ ); -+ await using var output = new ThrowingWriteStream(); -+ await using var error = new MemoryStream(); -+ -+ var status = await Command.RunAsync( [ "--verbose" ], input, output, error ); -+ -+ Assert.Equal( 1, status ); -+ Assert.Contains( -+ "simulated broken pipe", -+ Encoding.UTF8.GetString( error.ToArray() ), -+ StringComparison.Ordinal -+ ); -+ } -+ -+ /// Verifies that command orchestration does not impose a small line-length limit. -+ [Fact] -+ public async Task PreservesLongLines() { -+ var longLine = new string( 'x', 131072 ); -+ var result = await RunAsync( -+ string.Concat( "a\n", longLine, "\n.\np\nQ\n" ) -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( string.Concat( longLine, "\n" ), result.StandardOutput ); -+ } -+ -+ /// Verifies the segmented buffer through a command-level large-record-count script. -+ [Fact] -+ public async Task HandlesLargeBuffers() { -+ var builder = new StringBuilder( "a\n" ); -+ for ( var index = 0; 5000 > index; index++ ) { -+ builder.Append( "line-" ); -+ builder.Append( index ); -+ builder.Append( '\n' ); -+ } -+ builder.Append( ".\n$=\nQ\n" ); -+ -+ var result = await RunAsync( builder.ToString() ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "5000\n", result.StandardOutput ); -+ } -+ -+ /// Verifies textual interoperability with GNU and Icod Diffutils ed-script fixtures. -+ [Theory] -+ [InlineData( "gnu-diffutils" )] -+ [InlineData( "icod-diffutils" )] -+ public async Task AppliesDiffutilsEdScripts( -+ string fixtureName -+ ) { -+ var fixtureDirectory = System.IO.Path.Combine( -+ AppContext.BaseDirectory, -+ "fixtures", -+ fixtureName -+ ); -+ var inputPath = CreateTemporaryPath(); -+ var outputPath = CreateTemporaryPath(); -+ try { -+ File.Copy( System.IO.Path.Combine( fixtureDirectory, "original.txt" ), inputPath, overwrite: true ); -+ var script = await File.ReadAllTextAsync( System.IO.Path.Combine( fixtureDirectory, "change.ed" ) ); -+ script = string.Concat( script.TrimEnd( '\r', '\n' ), "\nw ", outputPath, "\nQ\n" ); -+ var result = await RunAsync( script, "-s", inputPath ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ Assert.Equal( -+ await File.ReadAllLinesAsync( System.IO.Path.Combine( fixtureDirectory, "expected.txt" ) ), -+ await File.ReadAllLinesAsync( outputPath ) -+ ); -+ } finally { -+ DeleteIfPresent( inputPath ); -+ DeleteIfPresent( outputPath ); -+ } -+ } -+ -+ /// Verifies that the text-only CommandContext compatibility path remains usable. -+ [Fact] -+ public async Task SupportsTextCommandContext() { -+ using var input = new StringReader( "a\nalpha\n.\np\nQ\n" ); -+ using var output = new StringWriter(); -+ using var error = new StringWriter(); -+ var context = new CommandContext( "ed", input, output, error ); -+ -+ var status = await Command.RunAsync( [], context ); -+ -+ Assert.Equal( 0, status ); -+ Assert.Equal( "alpha\n", output.ToString() ); -+ Assert.Equal( string.Empty, error.ToString() ); -+ } -+ -+ private static async Task RunAsync( -+ string script, -+ params string[] args -+ ) { -+ await using var input = new MemoryStream( Encoding.UTF8.GetBytes( script ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ var status = await Command.RunAsync( args, input, output, error ); -+ return new RunResult( -+ status, -+ Encoding.UTF8.GetString( output.ToArray() ), -+ Encoding.UTF8.GetString( error.ToArray() ) -+ ); -+ } -+ -+ private static string CreateTemporaryPath() => System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ string.Concat( ".icod-ed-test-", Guid.NewGuid().ToString( "N" ) ) -+ ); -+ -+ private static void DeleteIfPresent( -+ string path -+ ) { -+ try { -+ File.Delete( path ); -+ } catch ( IOException ) { -+ } catch ( UnauthorizedAccessException ) { -+ } -+ } -+ -+ private sealed record RunResult( -+ int Status, -+ string StandardOutput, -+ string StandardError -+ ); -+ -+ private sealed class ThrowingWriteStream : MemoryStream { -+ public override ValueTask WriteAsync( -+ ReadOnlyMemory buffer, -+ CancellationToken cancellationToken = default -+ ) => new( -+ Task.FromException( -+ new IOException( "simulated broken pipe" ) -+ ) -+ ); -+ } -+} -diff --git a/tests/Ed.Tests/src/README.md b/tests/Ed.Tests/src/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..2d8a62146a2a6d2e497b72a208f9013fef79c4eb ---- /dev/null -+++ b/tests/Ed.Tests/src/README.md -@@ -0,0 +1,8 @@ -+# Ed command tests -+ -+These tests exercise the LE7 `Icod.LineEditor.Ed.Command` orchestration boundary. -+They intentionally use the public command API rather than reaching into engine -+internals. The suite covers GNU invocation options, the standard and restricted -+capability profiles, byte-oriented command input, output and error status -+mapping, resource-scale cases, and textual compatibility with independent GNU -+Diffutils and Icod Diffutils ed-script fixtures. -diff --git a/tests/Red.Tests/Icod.LineEditor.Red.Tests.csproj b/tests/Red.Tests/Icod.LineEditor.Red.Tests.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..52d382d7cc9d8696ed05f05c628d41003eecb464 ---- /dev/null -+++ b/tests/Red.Tests/Icod.LineEditor.Red.Tests.csproj -@@ -0,0 +1,55 @@ -+ -+ -+ -+ net10.0 -+ 13.0 -+ enable -+ enable -+ false -+ true -+ Icod.LineEditor.Red.Tests -+ Icod.LineEditor.Red.Tests -+ -+ -+ -+ -+ -+ runtime; build; native; contentfiles; analyzers; buildtransitive -+ all -+ -+ -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/tests/Red.Tests/README.md b/tests/Red.Tests/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..5caaa5cbd63668f1751849618a9bb903fde7bdbd ---- /dev/null -+++ b/tests/Red.Tests/README.md -@@ -0,0 +1,5 @@ -+# Icod.LineEditor.Red.Tests -+ -+Command-level tests for the permanently restricted `red` executable. -+ -+The project verifies public identity, ordinary editing, equivalence with `ed --restricted`, direct and nested shell denial, host-independent pathname rejection, compatibility handling of `-r`, and permitted simple-name file operations beneath the captured working directory. -diff --git a/tests/Red.Tests/src/CommandTests.cs b/tests/Red.Tests/src/CommandTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..390dfcc9dccfec04990b8052dfaedc3aa4fe9662 ---- /dev/null -+++ b/tests/Red.Tests/src/CommandTests.cs -@@ -0,0 +1,189 @@ -+namespace Icod.LineEditor.Red.Tests; -+ -+using System.Text; -+using EdCommand = Icod.LineEditor.Ed.Command; -+using RedCommand = Icod.LineEditor.Red.Command; -+using Xunit; -+ -+/// Exercises the permanently restricted red command boundary. -+public sealed class CommandTests { -+ [Theory] -+ [InlineData( "--help", "Usage: red" )] -+ [InlineData( "--version", "red (Icod.CoreUtils)" )] -+ public async Task ReportsRedHelpAndVersion( -+ string option, -+ string expected -+ ) { -+ var result = await RunRedAsync( string.Empty, option ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Contains( expected, result.StandardOutput, StringComparison.Ordinal ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ [Fact] -+ public async Task ExecutesOrdinaryEditorCommands() { -+ var result = await RunRedAsync( "a\nalpha\nbeta\n.\n1,2p\nQ\n" ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "alpha\nbeta\n", result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ } -+ -+ [Theory] -+ [InlineData( "a\none\ntwo\n.\n2s/two/TWO/\n1,2p\nQ\n" )] -+ [InlineData( "a\none\n.\nZ\n" )] -+ public async Task MatchesEdRestrictedProfile( -+ string script -+ ) { -+ var red = await RunRedAsync( script ); -+ var ed = await RunEdAsync( script, "--restricted" ); -+ -+ Assert.Equal( ed, red ); -+ } -+ -+ [Fact] -+ public async Task AcceptsExplicitRestrictedOption() { -+ var result = await RunRedAsync( "a\none\n.\np\nQ\n", "-r" ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( "one\n", result.StandardOutput ); -+ } -+ -+ [Theory] -+ [InlineData( "! echo should-not-run\n" )] -+ [InlineData( "!!\n" )] -+ [InlineData( "a\none\n.\n1! cat\n" )] -+ public async Task DeniesShellCommands( -+ string script -+ ) { -+ var result = await RunRedAsync( script ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( "?\n", result.StandardError ); -+ } -+ -+ [Fact] -+ public async Task DeniesShellNestedInsideGlobalCommand() { -+ var result = await RunRedAsync( -+ "a\none\ntwo\n.\ng/two/! echo should-not-run\n" -+ ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( "?\n", result.StandardError ); -+ } -+ -+ [Fact] -+ public async Task DeniesShellInitialFileOperand() { -+ var result = await RunRedAsync( "Q\n", "!echo should-not-run" ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.StartsWith( "red: ", result.StandardError ); -+ Assert.Contains( "Shell input is disabled", result.StandardError, StringComparison.Ordinal ); -+ } -+ -+ [Theory] -+ [InlineData( "../outside" )] -+ [InlineData( "/absolute" )] -+ [InlineData( "dir/file" )] -+ [InlineData( "dir\\file" )] -+ [InlineData( "C:relative" )] -+ [InlineData( "C:\\absolute" )] -+ [InlineData( "\\\\server\\share" )] -+ [InlineData( "\\\\?\\C:\\device" )] -+ [InlineData( "stream:name" )] -+ [InlineData( "CON" )] -+ [InlineData( "nul.txt" )] -+ [InlineData( "COM1.log" )] -+ [InlineData( "LPT9" )] -+ [InlineData( "leaf." )] -+ [InlineData( "leaf " )] -+ public async Task DeniesPathBearingFileOperands( -+ string path -+ ) { -+ var result = await RunRedAsync( "Q\n", path ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.StartsWith( "red: ", result.StandardError ); -+ Assert.Contains( "simple filename", result.StandardError, StringComparison.Ordinal ); -+ } -+ -+ [Fact] -+ public async Task UnsafeNamesDoesNotBypassRestrictedPathPolicy() { -+ var result = await RunRedAsync( "Q\n", "--unsafe-names", "../outside" ); -+ -+ Assert.Equal( 1, result.Status ); -+ Assert.StartsWith( "red: ", result.StandardError ); -+ Assert.Contains( "simple filename", result.StandardError, StringComparison.Ordinal ); -+ } -+ -+ [Fact] -+ public async Task AllowsSimpleNamesInCapturedWorkingDirectory() { -+ var inputName = string.Concat( ".icod-red-input-", Guid.NewGuid().ToString( "N" ), ".txt" ); -+ var outputName = string.Concat( ".icod-red-output-", Guid.NewGuid().ToString( "N" ), ".txt" ); -+ var inputPath = System.IO.Path.Combine( Directory.GetCurrentDirectory(), inputName ); -+ var outputPath = System.IO.Path.Combine( Directory.GetCurrentDirectory(), outputName ); -+ try { -+ await File.WriteAllTextAsync( inputPath, "one\ntwo\n" ); -+ var result = await RunRedAsync( -+ string.Concat( "2s/two/TWO/\nw ", outputName, "\nQ\n" ), -+ "-s", -+ inputName -+ ); -+ -+ Assert.Equal( 0, result.Status ); -+ Assert.Equal( string.Empty, result.StandardOutput ); -+ Assert.Equal( string.Empty, result.StandardError ); -+ Assert.Equal( "one\nTWO\n", await File.ReadAllTextAsync( outputPath ) ); -+ } finally { -+ DeleteIfPresent( inputPath ); -+ DeleteIfPresent( outputPath ); -+ } -+ } -+ -+ private static Task RunRedAsync( -+ string script, -+ params string[] args -+ ) => RunAsync( RedCommand.RunAsync, script, args ); -+ -+ private static Task RunEdAsync( -+ string script, -+ params string[] args -+ ) => RunAsync( EdCommand.RunAsync, script, args ); -+ -+ private static async Task RunAsync( -+ Func> command, -+ string script, -+ string[] args -+ ) { -+ await using var input = new MemoryStream( Encoding.UTF8.GetBytes( script ), writable: false ); -+ await using var output = new MemoryStream(); -+ await using var error = new MemoryStream(); -+ var status = await command( args, input, output, error, CancellationToken.None ); -+ return new RunResult( -+ status, -+ Encoding.UTF8.GetString( output.ToArray() ), -+ Encoding.UTF8.GetString( error.ToArray() ) -+ ); -+ } -+ -+ private static void DeleteIfPresent( -+ string path -+ ) { -+ try { -+ File.Delete( path ); -+ } catch ( IOException ) { -+ } catch ( UnauthorizedAccessException ) { -+ } -+ } -+ -+ private sealed record RunResult( -+ int Status, -+ string StandardOutput, -+ string StandardError -+ ); -+} -diff --git a/tests/Sed.Tests/Icod.LineEditor.Sed.Tests.csproj b/tests/Sed.Tests/Icod.LineEditor.Sed.Tests.csproj -new file mode 100644 -index 0000000000000000000000000000000000000000..7f122817bf0a6a2d36e6cbb118554563543935d9 ---- /dev/null -+++ b/tests/Sed.Tests/Icod.LineEditor.Sed.Tests.csproj -@@ -0,0 +1,53 @@ -+ -+ -+ net10.0 -+ 13.0 -+ enable -+ enable -+ false -+ true -+ Icod.LineEditor.Sed.Tests -+ Icod.LineEditor.Sed.Tests -+ -+ -+ -+ -+ -+ runtime; build; native; contentfiles; analyzers; buildtransitive -+ all -+ -+ -+ -+ -+ -+ -+ -+ prompt -+ 2 -+ true -+ full -+ false -+ DEBUG;TRACE -+ false -+ false -+ -+ -+ prompt -+ 3 -+ true -+ full -+ false -+ TRACE -+ false -+ false -+ -+ -+ prompt -+ 4 -+ pdbonly -+ true -+ false -+ true -+ CS1591 -+ -+ -diff --git a/tests/Sed.Tests/src/ArchitectureBoundaryTests.cs b/tests/Sed.Tests/src/ArchitectureBoundaryTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..77cfd58ac47ca023bba08a8220740334506c03e8 ---- /dev/null -+++ b/tests/Sed.Tests/src/ArchitectureBoundaryTests.cs -@@ -0,0 +1,31 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using Xunit; -+ -+/// -+/// Locks the dependency direction established by the Phase LE9 sharing audit. -+/// -+public sealed class ArchitectureBoundaryTests { -+ /// -+ /// Verifies that Sed consumes the neutral command framework without -+ /// taking a dependency on the Ed/Red engine, an executable, or a speculative -+ /// LineEditor-family wrapper. -+ /// -+ [Fact] -+ public void SedReferencesOnlyTheNeutralFoundationWithinTheFamily() { -+ var references = typeof( Icod.LineEditor.Sed.Command ) -+ .Assembly -+ .GetReferencedAssemblies() -+ .Select( reference => reference.Name ?? string.Empty ) -+ .ToArray(); -+ -+ Assert.Contains( "Icod.CommandFramework", references ); -+ Assert.DoesNotContain( "Icod.CoreUtils.Shared", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Ed.Shared", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Shared", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Ed", references ); -+ Assert.DoesNotContain( "Icod.LineEditor.Red", references ); -+ Assert.DoesNotContain( "ed", references ); -+ Assert.DoesNotContain( "red", references ); -+ } -+} -diff --git a/tests/Sed.Tests/src/README.md b/tests/Sed.Tests/src/README.md -new file mode 100644 -index 0000000000000000000000000000000000000000..9eb62742dfc6324a29b80ec5bab5edc86988382c ---- /dev/null -+++ b/tests/Sed.Tests/src/README.md -@@ -0,0 +1,21 @@ -+# Icod.LineEditor.Sed tests -+ -+This directory contains the established command-level Sed suite, the LE1 decomposition coverage, the LE3 Shared-regex migration suite, the LE4 byte-record/text-semantic suite, and the LE5 orchestration/capability suite. -+ -+| File | Purpose | -+|---|---| -+| `SedCommandTests.cs` | Existing command-level behavior and conformance coverage retained unchanged. | -+| `SedCharacterizationTests.cs` | LE1 behavior freeze for option ordering, script-source ordering, diagnostics, implicit script mode, current record termination, sandbox denial, and in-place-edit startup. | -+| `SedModuleBoundaryTests.cs` | Focused structural tests preserving the public `Command` signatures and private implementation boundary during decomposition and regex migration. | -+| `SedRegularExpressionMigrationTests.cs` | GNU Sed 4.10 differential cases for BRE, ERE, captures, leftmost-longest selection, repeated zero-length matches, empty-expression reuse, modifiers, GNU escape preprocessing, strict-POSIX bracket policy, locale classes, and controlled diagnostics. | -+| `SedRecordAndTextSemanticsTests.cs` | LE4 coverage for CR preservation, explicit LF/NUL framing, final termination, invalid UTF-8, C-byte versus UTF-8 profiles, huge records, LF/NUL multiline pattern and hold space, NUL-aware dot/anchors/list output, repeated-output separation, separate-file framing, and record metadata. | -+| `SedOrchestrationAndCapabilityTests.cs` | LE5 coverage for the `CommandContext` byte path, named script sources, LF-only composition, injected shell and auxiliary files, sandbox runtime denial, in-place delegation, failure injection, and temporary cleanup. | -+ -+LE4 intentionally updates the LE1 unterminated-final-record characterization: a missing final separator is now preserved. Later semantic phases should update only characterization assertions whose behavior is intentionally changed by the roadmap. They must not delete the command-level suite merely because equivalent lower-level coverage is introduced. -+ -+LE3 removes the private `System.Text.RegularExpressions` translation path. The migration tests deliberately exercise behavior where .NET leftmost-first selection and default Unicode character classes differ from GNU/POSIX Sed expectations. -+ -+LE4 tests that modify `LC_ALL`, `LC_CTYPE`, or `LANG` are placed in a nonparallel xUnit collection because those values are process-wide. -+ -+ -+LE5 capability tests use internals only through the test assembly's `InternalsVisibleTo` grant. No new implementation type is part of Sed's public API. -diff --git a/tests/Sed.Tests/src/SedCharacterizationTests.cs b/tests/Sed.Tests/src/SedCharacterizationTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..92182ef7b2bba489add27bfe8a91bbb9253d735d ---- /dev/null -+++ b/tests/Sed.Tests/src/SedCharacterizationTests.cs -@@ -0,0 +1,189 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Text; -+using SedCommand = Icod.LineEditor.Sed.Command; -+using Xunit; -+ -+/// -+/// Captures command behavior that Phase LE1 must preserve while the Sed implementation is decomposed. -+/// -+public sealed class SedCharacterizationTests { -+ -+ /// -+ /// Verifies that option placement around explicit script sources does not change the compiled program. -+ /// -+ [Fact] -+ public async Task OptionOrderingAroundExplicitScriptsIsStable() { -+ var leading = await RunAsync( -+ new string[] { "-n", "-e", "s/a/A/", "-e", "p" }, -+ "a\n" -+ ); -+ var trailing = await RunAsync( -+ new string[] { "-e", "s/a/A/", "-e", "p", "-n" }, -+ "a\n" -+ ); -+ -+ Assert.Equal( 0, leading.ExitCode ); -+ Assert.Equal( leading.ExitCode, trailing.ExitCode ); -+ Assert.Equal( "A\n", leading.Output ); -+ Assert.Equal( leading.Output, trailing.Output ); -+ } -+ -+ /// -+ /// Verifies that expression and script-file sources are compiled in command-line encounter order. -+ /// -+ [Fact] -+ public async Task MultipleScriptSourcesRetainEncounterOrder() { -+ var scriptPath = await CreateFileAsync( "s/b/c/" ); -+ try { -+ var expressionThenFile = await RunAsync( -+ new string[] { "-e", "s/a/b/", "-f", scriptPath }, -+ "a\n" -+ ); -+ var fileThenExpression = await RunAsync( -+ new string[] { "-f", scriptPath, "-e", "s/a/b/" }, -+ "a\n" -+ ); -+ -+ Assert.Equal( 0, expressionThenFile.ExitCode ); -+ Assert.Equal( 0, fileThenExpression.ExitCode ); -+ Assert.Equal( "c\n", expressionThenFile.Output ); -+ Assert.Equal( "b\n", fileThenExpression.Output ); -+ } finally { -+ File.Delete( scriptPath ); -+ } -+ } -+ -+ /// -+ /// Verifies that malformed script files produce a controlled usage failure rather than escaping the command boundary. -+ /// -+ [Fact] -+ public async Task MalformedScriptFileProducesControlledDiagnostic() { -+ var scriptPath = await CreateFileAsync( "s/[a-/x/\n" ); -+ try { -+ var result = await RunAsync( -+ new string[] { "-f", scriptPath }, -+ "alpha\n" -+ ); -+ -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Empty( result.Output ); -+ Assert.Contains( "invalid regular expression", result.Error ); -+ } finally { -+ File.Delete( scriptPath ); -+ } -+ } -+ -+ /// -+ /// Verifies that the first non-option operand remains the implicit script when no explicit script source is present. -+ /// -+ [Fact] -+ public async Task ImplicitScriptOperandRemainsTheProgram() { -+ var result = await RunAsync( -+ new string[] { "-n", "p" }, -+ "alpha\n" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "alpha\n", result.Output ); -+ } -+ -+ /// -+ /// Verifies the LE4 contract that an unterminated final input record remains unterminated. -+ /// -+ [Fact] -+ public async Task UnterminatedFinalRecordRemainsUnterminatedOnOutput() { -+ var result = await RunAsync( -+ new string[] { "s/alpha/beta/" }, -+ "alpha" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "beta", result.Output ); -+ } -+ -+ /// -+ /// Verifies that sandbox rejection happens during script compilation and does not create the requested output file. -+ /// -+ [Fact] -+ public async Task SandboxRejectsFileEffectsFromScriptFiles() { -+ var outputPath = $"icod-sed-sandbox-{Guid.NewGuid():N}.txt"; -+ var scriptPath = await CreateFileAsync( $"w {outputPath}\n" ); -+ try { -+ var result = await RunAsync( -+ new string[] { "--sandbox", "-f", scriptPath }, -+ "alpha\n" -+ ); -+ -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Contains( "disabled in sandbox mode", result.Error ); -+ Assert.False( File.Exists( outputPath ) ); -+ } finally { -+ File.Delete( scriptPath ); -+ File.Delete( outputPath ); -+ } -+ } -+ -+ /// -+ /// Verifies that a script-compilation failure cannot begin an in-place edit or create its backup. -+ /// -+ [Fact] -+ public async Task InvalidProgramCannotBeginInPlaceEditing() { -+ var inputPath = await CreateFileAsync( "alpha\n" ); -+ var backupPath = inputPath + ".bak"; -+ try { -+ var result = await RunAsync( -+ new string[] { "-i.bak", "s/[a-/x/", inputPath }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Equal( "alpha\n", await File.ReadAllTextAsync( inputPath ) ); -+ Assert.False( File.Exists( backupPath ) ); -+ } finally { -+ File.Delete( inputPath ); -+ File.Delete( backupPath ); -+ } -+ } -+ -+ private static async Task CreateFileAsync( -+ string contents -+ ) { -+ var path = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $"icod-sed-characterization-{Guid.NewGuid():N}.txt" -+ ); -+ await File.WriteAllTextAsync( -+ path, -+ contents, -+ new UTF8Encoding( false ) -+ ); -+ return path; -+ } -+ -+ private static async Task RunAsync( -+ string[] args, -+ string input -+ ) { -+ using var output = new StringWriter { NewLine = "\n" }; -+ using var error = new StringWriter { NewLine = "\n" }; -+ var exitCode = await SedCommand.RunAsync( -+ args, -+ new StringReader( input ), -+ output, -+ error -+ ); -+ return new CommandResult( -+ exitCode, -+ output.ToString(), -+ error.ToString() -+ ); -+ } -+ -+ private sealed record CommandResult( -+ int ExitCode, -+ string Output, -+ string Error -+ ); -+ -+} -diff --git a/tests/Sed.Tests/src/SedCommandTests.cs b/tests/Sed.Tests/src/SedCommandTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..f077ae36b81b4efaa2d325416159149ad4081aed ---- /dev/null -+++ b/tests/Sed.Tests/src/SedCommandTests.cs -@@ -0,0 +1,514 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Text; -+using SedCommand = Icod.LineEditor.Sed.Command; -+using Xunit; -+ -+public sealed class SedCommandTests { -+ -+ [Fact] -+ public async Task SubstitutionUsesAutomaticPrinting() { -+ var result = await RunAsync( -+ new string[] { "s/alpha/beta/" }, -+ "alpha\nother\n" -+ ); -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "beta\nother\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task QuietAndPrintSelectMatchingLines() { -+ var result = await RunAsync( -+ new string[] { "-n", "/two/p" }, -+ "one\ntwo\nthree\n" -+ ); -+ Assert.Equal( "two\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task NumericRangeAndNegationAreInclusive() { -+ var range = await RunAsync( -+ new string[] { "2,3d" }, -+ "one\ntwo\nthree\nfour\n" -+ ); -+ var negated = await RunAsync( -+ new string[] { "-n", "2,3p" }, -+ "one\ntwo\nthree\nfour\n" -+ ); -+ Assert.Equal( "one\nfour\n", range.Output ); -+ Assert.Equal( "two\nthree\n", negated.Output ); -+ } -+ -+ [Fact] -+ public async Task LastAddressAndGroupedCommandsWork() { -+ var result = await RunAsync( -+ new string[] { "-n", "${s/three/THREE/;p;}" }, -+ "one\ntwo\nthree\n" -+ ); -+ Assert.Equal( "THREE\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task HoldSpaceCommandsWork() { -+ var result = await RunAsync( -+ new string[] { "-n", "1h;2{g;p;}" }, -+ "alpha\nbeta\n" -+ ); -+ Assert.Equal( "alpha\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task BranchesAndLabelsWork() { -+ var result = await RunAsync( -+ new string[] { "-n", ":again;s/aa/a/;t again;p" }, -+ "aaaa\n" -+ ); -+ Assert.Equal( "a\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task MultiLinePatternCommandsWork() { -+ var result = await RunAsync( -+ new string[] { "-n", "N;P;D" }, -+ "one\ntwo\nthree\n" -+ ); -+ Assert.Equal( "one\ntwo\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task TransliterationWorks() { -+ var result = await RunAsync( -+ new string[] { "y/abc/ABC/" }, -+ "cab\n" -+ ); -+ Assert.Equal( "CAB\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task SubstitutionOccurrenceGlobalAndBackReferenceWork() { -+ var occurrence = await RunAsync( -+ new string[] { "s/a/A/2g" }, -+ "aaaa\n" -+ ); -+ var backReference = await RunAsync( -+ new string[] { @"s/\(ab\)/[\1]/" }, -+ "ab\n" -+ ); -+ Assert.Equal( "aAAA\n", occurrence.Output ); -+ Assert.Equal( "[ab]\n", backReference.Output ); -+ } -+ -+ [Fact] -+ public async Task ExtendedRegularExpressionsAreSupported() { -+ var result = await RunAsync( -+ new string[] { "-E", "s/(ab)+/X/" }, -+ "abab\n" -+ ); -+ Assert.Equal( "X\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task AppendInsertAndChangeCommandsPreserveCycleOrdering() { -+ var append = await RunAsync( -+ new string[] { "1a appended" }, -+ "one\ntwo\n" -+ ); -+ var insert = await RunAsync( -+ new string[] { "1i inserted" }, -+ "one\n" -+ ); -+ var change = await RunAsync( -+ new string[] { "1,2c changed" }, -+ "one\ntwo\nthree\n" -+ ); -+ Assert.Equal( "one\nappended\ntwo\n", append.Output ); -+ Assert.Equal( "inserted\none\n", insert.Output ); -+ Assert.Equal( "changed\nthree\n", change.Output ); -+ } -+ -+ [Fact] -+ public async Task LineNumberAndListCommandsWork() { -+ var numbered = await RunAsync( -+ new string[] { "-n", "2=;2p" }, -+ "one\ntwo\n" -+ ); -+ var listed = await RunAsync( -+ new string[] { "-n", "l" }, -+ "a\tb\n" -+ ); -+ Assert.Equal( "2\ntwo\n", numbered.Output ); -+ Assert.Equal( "a\\tb$\n", listed.Output ); -+ } -+ -+ [Fact] -+ public async Task GnuAddressExtensionsWorkOutsidePosixMode() { -+ var step = await RunAsync( -+ new string[] { "-n", "1~2p" }, -+ "one\ntwo\nthree\nfour\nfive\n" -+ ); -+ var relative = await RunAsync( -+ new string[] { "-n", "2,+2p" }, -+ "one\ntwo\nthree\nfour\nfive\n" -+ ); -+ var zero = await RunAsync( -+ new string[] { "-n", "0,/two/p" }, -+ "one\ntwo\nthree\n" -+ ); -+ Assert.Equal( "one\nthree\nfive\n", step.Output ); -+ Assert.Equal( "two\nthree\nfour\n", relative.Output ); -+ Assert.Equal( "one\ntwo\n", zero.Output ); -+ } -+ -+ [Fact] -+ public async Task EmptyRegularExpressionReusesPreviousExpression() { -+ var result = await RunAsync( -+ new string[] { "-n", "/two/p;//p" }, -+ "one\ntwo\nthree\n" -+ ); -+ Assert.Equal( "two\ntwo\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task QuitCommandsReturnRequestedExitCodes() { -+ var normal = await RunAsync( -+ new string[] { "q7" }, -+ "alpha\nbeta\n" -+ ); -+ var silent = await RunAsync( -+ new string[] { "Q9" }, -+ "alpha\nbeta\n" -+ ); -+ Assert.Equal( 7, normal.ExitCode ); -+ Assert.Equal( "alpha\n", normal.Output ); -+ Assert.Equal( 9, silent.ExitCode ); -+ Assert.Equal( string.Empty, silent.Output ); -+ } -+ -+ [Fact] -+ public async Task ScriptFileIsReadAsynchronously() { -+ var scriptPath = await CreateFileAsync( -+ "s/alpha/beta/\n" -+ ); -+ try { -+ var result = await RunAsync( -+ new string[] { "-f", scriptPath }, -+ "alpha\n" -+ ); -+ Assert.Equal( "beta\n", result.Output ); -+ } finally { -+ File.Delete( scriptPath ); -+ } -+ } -+ -+ [Fact] -+ public async Task SeparateModeResetsLastAddressForEachFile() { -+ var first = await CreateFileAsync( "one\ntwo\n" ); -+ var second = await CreateFileAsync( "three\nfour\n" ); -+ try { -+ var result = await RunAsync( -+ new string[] { "-s", "-n", "$p", first, second }, -+ string.Empty -+ ); -+ Assert.Equal( "two\nfour\n", result.Output ); -+ } finally { -+ File.Delete( first ); -+ File.Delete( second ); -+ } -+ } -+ -+ [Fact] -+ public async Task NullDataUsesNulDelimitedRecords() { -+ var result = await RunAsync( -+ new string[] { "-z", "s/beta/BETA/" }, -+ "alpha\0beta\0" -+ ); -+ Assert.Equal( "alpha\0BETA\0", result.Output ); -+ } -+ -+ [Fact] -+ public async Task ReadAndWriteCommandsStreamAuxiliaryFiles() { -+ var readPath = await CreateFileAsync( "extra\n" ); -+ var writePath = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $"icod-sed-write-{Guid.NewGuid():N}.txt" -+ ); -+ try { -+ var result = await RunAsync( -+ new string[] { $"1r {readPath};1w {writePath}" }, -+ "main\n" -+ ); -+ Assert.Equal( "main\nextra\n", result.Output ); -+ Assert.Equal( "main\n", await File.ReadAllTextAsync( writePath ) ); -+ } finally { -+ File.Delete( readPath ); -+ File.Delete( writePath ); -+ } -+ } -+ -+ [Fact] -+ public async Task SandboxRejectsFileAndExecutionCommands() { -+ var write = await RunAsync( -+ new string[] { "--sandbox", "w output.txt" }, -+ "alpha\n" -+ ); -+ var execute = await RunAsync( -+ new string[] { "--sandbox", "e echo alpha" }, -+ "alpha\n" -+ ); -+ Assert.Equal( 2, write.ExitCode ); -+ Assert.Equal( 2, execute.ExitCode ); -+ Assert.Contains( "disabled in sandbox mode", write.Error ); -+ Assert.Contains( "disabled in sandbox mode", execute.Error ); -+ } -+ -+ [Fact] -+ public async Task PosixModeRejectsGnuExtensions() { -+ var command = await RunAsync( -+ new string[] { "--posix", "Q" }, -+ "alpha\n" -+ ); -+ var address = await RunAsync( -+ new string[] { "--posix", "1~2p" }, -+ "alpha\n" -+ ); -+ var quitCode = await RunAsync( -+ new string[] { "--posix", "q7" }, -+ "alpha\n" -+ ); -+ Assert.Equal( 2, command.ExitCode ); -+ Assert.Equal( 2, address.ExitCode ); -+ Assert.Equal( 2, quitCode.ExitCode ); -+ Assert.Contains( "POSIX mode", command.Error ); -+ Assert.Contains( "POSIX mode", address.Error ); -+ Assert.Contains( "POSIX mode", quitCode.Error ); -+ } -+ -+ [Fact] -+ public async Task InvalidSubstitutionFlagIsRejected() { -+ var result = await RunAsync( -+ new string[] { "s/a/b/x" }, -+ "alpha\n" -+ ); -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Contains( "unknown substitution flag", result.Error ); -+ } -+ -+ [Theory] -+ [InlineData( "s/[a-/x/", "invalid regular expression" )] -+ [InlineData( "y/ab/c/", "equal lengths" )] -+ [InlineData( "s/a/b/0", "positive integer" )] -+ public async Task InvalidProgramsReturnUsageErrors( -+ string script, -+ string expectedDiagnostic -+ ) { -+ var result = await RunAsync( -+ new string[] { script }, -+ "alpha\n" -+ ); -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Contains( expectedDiagnostic, result.Error ); -+ } -+ -+ [Fact] -+ public async Task DebugAnnotatesProgramAndCycles() { -+ var result = await RunAsync( -+ new string[] { "--debug", "s/a/b/" }, -+ "a\n" -+ ); -+ Assert.Equal( "b\n", result.Output ); -+ Assert.Contains( "SED PROGRAM:", result.Error ); -+ Assert.Contains( "INPUT:", result.Error ); -+ Assert.Contains( "PATTERN:", result.Error ); -+ } -+ -+ [Fact] -+ public async Task ExecuteCommandRunsThroughPlatformShell() { -+ var command = OperatingSystem.IsWindows() -+ ? "echo batch2" -+ : "printf batch2" -+ ; -+ var result = await RunAsync( -+ new string[] { "-n", $"e {command}" }, -+ "ignored\n" -+ ); -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Contains( "batch2", result.Output ); -+ } -+ -+ [Fact] -+ public async Task SubstitutionExecuteFlagReplacesPatternSpace() { -+ var shellText = OperatingSystem.IsWindows() -+ ? "echo batch2" -+ : "printf batch2" -+ ; -+ var result = await RunAsync( -+ new string[] { "-n", $"s/.*/{shellText}/e;p" }, -+ "ignored\n" -+ ); -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "batch2\n", result.Output ); -+ } -+ -+ [Fact] -+ public async Task InPlaceEditingCreatesBackupAndPreservesMode() { -+ var path = await CreateFileAsync( -+ "alpha\n" -+ ); -+ var backup = path + ".bak"; -+ UnixFileMode? originalMode = null; -+ if ( !OperatingSystem.IsWindows() ) { -+ originalMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; -+ File.SetUnixFileMode( -+ path, -+ originalMode.Value -+ ); -+ } -+ try { -+ var result = await RunAsync( -+ new string[] { "-i.bak", "s/alpha/beta/", path }, -+ string.Empty -+ ); -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "beta\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Equal( "alpha\n", await File.ReadAllTextAsync( backup ) ); -+ if ( -+ !OperatingSystem.IsWindows() -+ && originalMode.HasValue -+ ) { -+ Assert.Equal( -+ originalMode.Value, -+ File.GetUnixFileMode( -+ path -+ ) -+ ); -+ } -+ } finally { -+ File.Delete( path ); -+ File.Delete( backup ); -+ } -+ } -+ -+ [Fact] -+ public async Task InPlaceBackupSuffixMayContainWildcard() { -+ var directory = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $"icod-sed-dir-{Guid.NewGuid():N}" -+ ); -+ Directory.CreateDirectory( directory ); -+ var path = System.IO.Path.Combine( directory, "input.txt" ); -+ await File.WriteAllTextAsync( path, "alpha\n", new UTF8Encoding( false ) ); -+ var backup = path + ".orig"; -+ try { -+ var result = await RunAsync( -+ new string[] { "--in-place=*.orig", "s/alpha/beta/", path }, -+ string.Empty -+ ); -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.True( File.Exists( backup ) ); -+ } finally { -+ Directory.Delete( directory, recursive: true ); -+ } -+ } -+ -+ [Fact] -+ public async Task FollowSymlinksEditsTargetWhenSupported() { -+ var directory = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $"icod-sed-link-{Guid.NewGuid():N}" -+ ); -+ Directory.CreateDirectory( directory ); -+ var target = System.IO.Path.Combine( directory, "target.txt" ); -+ var link = System.IO.Path.Combine( directory, "link.txt" ); -+ await File.WriteAllTextAsync( target, "alpha\n", new UTF8Encoding( false ) ); -+ try { -+ try { -+ File.CreateSymbolicLink( link, target ); -+ } catch ( -+ Exception ex -+ ) when ( -+ ex is UnauthorizedAccessException -+ or PlatformNotSupportedException -+ or IOException -+ ) { -+ return; -+ } -+ var result = await RunAsync( -+ new string[] { "--follow-symlinks", "-i", "s/alpha/beta/", link }, -+ string.Empty -+ ); -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "beta\n", await File.ReadAllTextAsync( target ) ); -+ Assert.NotNull( new FileInfo( link ).LinkTarget ); -+ } finally { -+ Directory.Delete( directory, recursive: true ); -+ } -+ } -+ -+ [Fact] -+ public async Task HelpVersionAndUnknownOptionsUseSharedParser() { -+ var help = await RunAsync( new string[] { "--help" }, string.Empty ); -+ var version = await RunAsync( new string[] { "--version" }, string.Empty ); -+ var invalid = await RunAsync( new string[] { "--not-an-option" }, string.Empty ); -+ Assert.Equal( 0, help.ExitCode ); -+ Assert.Contains( "Usage: sed", help.Output ); -+ Assert.Equal( 0, version.ExitCode ); -+ Assert.Contains( "Icod.LineEditor.Sed", version.Output ); -+ Assert.Equal( 2, invalid.ExitCode ); -+ Assert.Contains( "unrecognized option", invalid.Error ); -+ } -+ -+ [Fact] -+ public async Task CancellationReturnsConventionalExitCode() { -+ using var cancellation = new CancellationTokenSource(); -+ cancellation.Cancel(); -+ var result = await RunAsync( -+ new string[] { "p" }, -+ "alpha\n", -+ cancellation.Token -+ ); -+ Assert.Equal( 130, result.ExitCode ); -+ } -+ -+ private static async Task CreateFileAsync( -+ string contents -+ ) { -+ var path = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $"icod-sed-test-{Guid.NewGuid():N}.txt" -+ ); -+ await File.WriteAllTextAsync( -+ path, -+ contents, -+ new UTF8Encoding( false ) -+ ); -+ return path; -+ } -+ -+ private static async Task RunAsync( -+ string[] args, -+ string input, -+ CancellationToken cancellationToken = default -+ ) { -+ using var output = new StringWriter { NewLine = "\n" }; -+ using var error = new StringWriter { NewLine = "\n" }; -+ var exitCode = await SedCommand.RunAsync( -+ args, -+ new StringReader( input ), -+ output, -+ error, -+ cancellationToken -+ ); -+ return new CommandResult( -+ exitCode, -+ output.ToString(), -+ error.ToString() -+ ); -+ } -+ -+ private sealed record CommandResult( -+ int ExitCode, -+ string Output, -+ string Error -+ ); -+ -+} -diff --git a/tests/Sed.Tests/src/SedModuleBoundaryTests.cs b/tests/Sed.Tests/src/SedModuleBoundaryTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..92e3e862d57f90d51d558fd314727e13a8cede20 ---- /dev/null -+++ b/tests/Sed.Tests/src/SedModuleBoundaryTests.cs -@@ -0,0 +1,76 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Reflection; -+using Xunit; -+ -+/// -+/// Verifies the public and private boundaries retained by the LE1 source decomposition. -+/// -+public sealed class SedModuleBoundaryTests { -+ -+ /// -+ /// Verifies that the established synchronous and asynchronous command signatures remain available. -+ /// -+ [Fact] -+ public void PublicCommandSignaturesRemainStable() { -+ var commandType = typeof( Icod.LineEditor.Sed.Command ); -+ -+ var run = commandType.GetMethod( -+ "Run", -+ BindingFlags.Public | BindingFlags.Static, -+ binder: null, -+ types: new Type[] { -+ typeof( string[] ), -+ typeof( TextReader ), -+ typeof( TextWriter ), -+ typeof( TextWriter ) -+ }, -+ modifiers: null -+ ); -+ var runAsync = commandType.GetMethod( -+ "RunAsync", -+ BindingFlags.Public | BindingFlags.Static, -+ binder: null, -+ types: new Type[] { -+ typeof( string[] ), -+ typeof( TextReader ), -+ typeof( TextWriter ), -+ typeof( TextWriter ), -+ typeof( CancellationToken ) -+ }, -+ modifiers: null -+ ); -+ -+ Assert.NotNull( run ); -+ Assert.Equal( typeof( int ), run!.ReturnType ); -+ Assert.NotNull( runAsync ); -+ Assert.Equal( typeof( Task ), runAsync!.ReturnType ); -+ } -+ -+ /// -+ /// Verifies that implementation types remain non-public details behind the command facade. -+ /// -+ [Theory] -+ [InlineData( "Options" )] -+ [InlineData( "ScriptParser" )] -+ [InlineData( "SedProgram" )] -+ [InlineData( "AddressSelector" )] -+ [InlineData( "InputSequence" )] -+ [InlineData( "ExecutionEnvironment" )] -+ [InlineData( "SubstitutionFlags" )] -+ [InlineData( "SedRegularExpressionCompiler" )] -+ [InlineData( "SedCompiledRegularExpression" )] -+ [InlineData( "TextWriterStream" )] -+ public void DecomposedImplementationTypesRemainPrivate( -+ string nestedTypeName -+ ) { -+ var nestedType = typeof( Icod.LineEditor.Sed.Command ).GetNestedType( -+ nestedTypeName, -+ BindingFlags.NonPublic -+ ); -+ -+ Assert.NotNull( nestedType ); -+ Assert.True( nestedType!.IsNestedPrivate ); -+ } -+ -+} -diff --git a/tests/Sed.Tests/src/SedOrchestrationAndCapabilityTests.cs b/tests/Sed.Tests/src/SedOrchestrationAndCapabilityTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..0ea18f53ba2b3a777f2241f7feb6292b43845e71 ---- /dev/null -+++ b/tests/Sed.Tests/src/SedOrchestrationAndCapabilityTests.cs -@@ -0,0 +1,421 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Text; -+using Icod.CommandFramework.Diagnostics; -+using Icod.CommandFramework.Temporary; -+using SedCommand = Icod.LineEditor.Sed.Command; -+using Xunit; -+ -+/// Verifies the orchestration and capability boundaries introduced by LE5. -+[Collection( "Sed environment" )] -+public sealed class SedOrchestrationAndCapabilityTests { -+ -+ /// Verifies that the CommandContext overload prefers authoritative byte streams. -+ [Fact] -+ public async Task CommandContextCoreUsesBinaryStreamsWhenAvailable() { -+ using var input = new MemoryStream( Encoding.UTF8.GetBytes( "alpha\n" ) ); -+ using var output = new MemoryStream(); -+ using var textOutput = new StringWriter(); -+ using var error = new StringWriter(); -+ var context = new CommandContext( -+ "sed", -+ TextReader.Null, -+ textOutput, -+ error, -+ input, -+ output -+ ); -+ -+ var exitCode = await SedCommand.RunAsync( -+ new string[] { "s/alpha/beta/" }, -+ context -+ ); -+ -+ Assert.Equal( 0, exitCode ); -+ Assert.Equal( "beta\n", Encoding.UTF8.GetString( output.ToArray() ) ); -+ Assert.Empty( textOutput.ToString() ); -+ Assert.Empty( error.ToString() ); -+ } -+ -+ /// Verifies that a binary input stream remains authoritative with text output. -+ [Fact] -+ public async Task CommandContextUsesBinaryInputIndependently() { -+ using var input = new MemoryStream( Encoding.UTF8.GetBytes( "alpha\n" ) ); -+ using var output = new StringWriter(); -+ using var error = new StringWriter(); -+ var context = new CommandContext( -+ "sed", -+ new StringReader( "wrong\n" ), -+ output, -+ error, -+ standardInputStream: input -+ ); -+ -+ var exitCode = await SedCommand.RunAsync( -+ new string[] { "s/alpha/beta/" }, -+ context -+ ); -+ -+ Assert.Equal( 0, exitCode ); -+ Assert.Equal( "beta\n", output.ToString() ); -+ Assert.Empty( error.ToString() ); -+ } -+ -+ /// Verifies that a binary output stream remains authoritative with text input. -+ [Fact] -+ public async Task CommandContextUsesBinaryOutputIndependently() { -+ using var output = new MemoryStream(); -+ using var textOutput = new StringWriter(); -+ using var error = new StringWriter(); -+ var context = new CommandContext( -+ "sed", -+ new StringReader( "alpha\n" ), -+ textOutput, -+ error, -+ standardOutputStream: output -+ ); -+ -+ var exitCode = await SedCommand.RunAsync( -+ new string[] { "s/alpha/beta/" }, -+ context -+ ); -+ -+ Assert.Equal( 0, exitCode ); -+ Assert.Equal( "beta\n", Encoding.UTF8.GetString( output.ToArray() ) ); -+ Assert.Empty( textOutput.ToString() ); -+ Assert.Empty( error.ToString() ); -+ } -+ -+ /// Verifies LF-only composition and aggregate-to-source location mapping. -+ [Fact] -+ public void ScriptDocumentPreservesNamedSourcesAndUsesLineFeedJoining() { -+ var first = new SedCommand.SedScriptSource( -+ SedCommand.SedScriptSourceKind.Expression, -+ "first expression", -+ "p", -+ 0 -+ ); -+ var second = new SedCommand.SedScriptSource( -+ SedCommand.SedScriptSourceKind.File, -+ "commands.sed", -+ "d\r\nq", -+ 1 -+ ); -+ -+ var document = SedCommand.SedScriptDocument.Create( -+ new SedCommand.SedScriptSource[] { first, second } -+ ); -+ var location = document.GetLocation( 2 ); -+ -+ Assert.Equal( "p\nd\r\nq", document.Text ); -+ Assert.Equal( -+ new string[] { "first expression", "commands.sed" }, -+ document.Sources.Select( source => source.Name ).ToArray() -+ ); -+ Assert.Equal( "commands.sed", location.SourceName ); -+ Assert.Equal( 1, location.Line ); -+ Assert.Equal( 1, location.Column ); -+ } -+ -+ /// Verifies that parser diagnostics identify the responsible script source. -+ [Fact] -+ public async Task InvalidLaterExpressionReportsItsStableSourceName() { -+ using var output = new StringWriter(); -+ using var error = new StringWriter(); -+ -+ var exitCode = await SedCommand.RunAsync( -+ new string[] { "-e", "p", "-e", "{" }, -+ new StringReader( "alpha\n" ), -+ output, -+ error -+ ); -+ -+ Assert.NotEqual( 0, exitCode ); -+ Assert.Contains( "-e expression #2", error.ToString(), StringComparison.Ordinal ); -+ Assert.Contains( ":1:", error.ToString(), StringComparison.Ordinal ); -+ } -+ -+ /// Verifies that substitution shell execution uses the injected capability. -+ [Fact] -+ public async Task SubstitutionExecuteUsesInjectedShellCapability() { -+ var shell = new RecordingShellCapability( -+ new SedCommand.ShellResult( 0, "from-shell\n" ) -+ ); -+ var auxiliary = new RecordingAuxiliaryFileCapability(); -+ var inPlace = new RecordingInPlaceEditor(); -+ var result = await RunWithCapabilitiesAsync( -+ new string[] { "-n", "s/x/y/ep" }, -+ "x\n", -+ new SedCommand.SedRuntimeCapabilities( shell, auxiliary, inPlace ) -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "from-shell\n", result.Output ); -+ Assert.Equal( 1, shell.CallCount ); -+ Assert.Equal( "y", shell.Commands.Single() ); -+ } -+ -+ /// Verifies that r and w use the injected auxiliary-file capability. -+ [Fact] -+ public async Task AuxiliaryReadAndWriteUseInjectedCapability() { -+ var shell = new RecordingShellCapability( -+ new SedCommand.ShellResult( 0, string.Empty ) -+ ); -+ var auxiliary = new RecordingAuxiliaryFileCapability(); -+ auxiliary.ReadFiles[ "virtual-input" ] = Encoding.UTF8.GetBytes( "auxiliary\n" ); -+ var capabilities = new SedCommand.SedRuntimeCapabilities( -+ shell, -+ auxiliary, -+ new RecordingInPlaceEditor() -+ ); -+ -+ var read = await RunWithCapabilitiesAsync( -+ new string[] { "r virtual-input" }, -+ "main\n", -+ capabilities -+ ); -+ var write = await RunWithCapabilitiesAsync( -+ new string[] { "-n", "w virtual-output" }, -+ "captured\n", -+ capabilities -+ ); -+ -+ Assert.Equal( 0, read.ExitCode ); -+ Assert.Equal( "main\nauxiliary\n", read.Output ); -+ Assert.Equal( 0, write.ExitCode ); -+ Assert.Equal( -+ "captured\n", -+ Encoding.UTF8.GetString( auxiliary.WrittenFiles[ "virtual-output" ].ToArray() ) -+ ); -+ } -+ -+ /// Verifies compile-time sandbox rejection and denied runtime backstops. -+ [Fact] -+ public async Task SandboxRejectsCommandsAndDeniesRuntimeCapabilities() { -+ var shell = new RecordingShellCapability( -+ new SedCommand.ShellResult( 0, string.Empty ) -+ ); -+ var auxiliary = new RecordingAuxiliaryFileCapability(); -+ var capabilities = new SedCommand.SedRuntimeCapabilities( -+ shell, -+ auxiliary, -+ new RecordingInPlaceEditor() -+ ); -+ -+ var result = await RunWithCapabilitiesAsync( -+ new string[] { "--sandbox", "e echo forbidden" }, -+ "alpha\n", -+ capabilities -+ ); -+ var sandbox = capabilities.ForSandbox(); -+ -+ Assert.NotEqual( 0, result.ExitCode ); -+ Assert.Contains( "sandbox", result.Error, StringComparison.OrdinalIgnoreCase ); -+ Assert.Equal( 0, shell.CallCount ); -+ await Assert.ThrowsAsync( -+ () => sandbox.AuxiliaryFiles.OpenReadAsync( -+ "forbidden", -+ CancellationToken.None -+ ).AsTask() -+ ); -+ await Assert.ThrowsAsync( -+ () => sandbox.Shell.ExecuteAsync( -+ "forbidden", -+ null!, -+ TextWriter.Null, -+ captureStandardOutput: true, -+ CancellationToken.None -+ ) -+ ); -+ } -+ -+ /// Verifies that in-place orchestration is delegated to the internal editor boundary. -+ [Fact] -+ public async Task InPlaceModeUsesInjectedEditorBoundary() { -+ var inPlace = new RecordingInPlaceEditor(); -+ var capabilities = new SedCommand.SedRuntimeCapabilities( -+ new RecordingShellCapability( new SedCommand.ShellResult( 0, string.Empty ) ), -+ new RecordingAuxiliaryFileCapability(), -+ inPlace -+ ); -+ -+ var result = await RunWithCapabilitiesAsync( -+ new string[] { "-i.bak", "s/a/A/", "virtual-file" }, -+ string.Empty, -+ capabilities -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ var request = Assert.Single( inPlace.Requests ); -+ Assert.Equal( "virtual-file", request.Path ); -+ Assert.Equal( ".bak", request.BackupSuffix ); -+ } -+ -+ /// Verifies cleanup and source preservation when a staged transform fails. -+ [Fact] -+ public async Task SystemInPlaceEditorCleansTemporaryFileAfterFailure() { -+ var directory = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $".icod-sed-le5-{Guid.NewGuid():N}" -+ ); -+ Directory.CreateDirectory( directory ); -+ var path = System.IO.Path.Combine( directory, "input.txt" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ var editor = new SedCommand.SystemInPlaceEditor( -+ SecureTemporaryObjectCreator.System -+ ); -+ try { -+ await Assert.ThrowsAsync( -+ () => editor.EditAsync( -+ new SedCommand.SedInPlaceEditRequest( -+ path, -+ FollowSymlinks: false, -+ BackupSuffix: null -+ ), -+ async ( -+ _, -+ output, -+ cancellationToken -+ ) => { -+ await output.WriteAsync( -+ Encoding.UTF8.GetBytes( "partial\n" ).AsMemory(), -+ cancellationToken -+ ); -+ throw new IOException( "injected transform failure" ); -+ }, -+ CancellationToken.None -+ ) -+ ); -+ -+ Assert.Equal( "original\n", await File.ReadAllTextAsync( path ) ); -+ Assert.DoesNotContain( -+ Directory.EnumerateFiles( directory ), -+ candidate => System.IO.Path.GetFileName( candidate ).StartsWith( -+ ".sed.", -+ StringComparison.Ordinal -+ ) -+ ); -+ } finally { -+ Directory.Delete( directory, recursive: true ); -+ } -+ } -+ -+ private static async Task RunWithCapabilitiesAsync( -+ string[] args, -+ string input, -+ SedCommand.SedRuntimeCapabilities capabilities -+ ) { -+ using var standardInput = new MemoryStream( Encoding.UTF8.GetBytes( input ) ); -+ using var standardOutput = new MemoryStream(); -+ using var presentation = new StringWriter(); -+ using var error = new StringWriter(); -+ var context = new CommandContext( -+ "sed", -+ TextReader.Null, -+ presentation, -+ error, -+ standardInput, -+ standardOutput -+ ); -+ var exitCode = await SedCommand.RunAsync( args, context, capabilities ); -+ return new CommandResult( -+ exitCode, -+ Encoding.UTF8.GetString( standardOutput.ToArray() ), -+ error.ToString() -+ ); -+ } -+ -+ private sealed record CommandResult( -+ int ExitCode, -+ string Output, -+ string Error -+ ); -+ -+ private sealed class RecordingShellCapability : SedCommand.ISedShellCapability { -+ -+ private readonly SedCommand.ShellResult myResult; -+ -+ public int CallCount { -+ get; -+ private set; -+ } -+ -+ public List Commands { -+ get; -+ } = new(); -+ -+ public RecordingShellCapability( -+ SedCommand.ShellResult result -+ ) { -+ this.myResult = result; -+ } -+ -+ public Task ExecuteAsync( -+ string command, -+ TextWriter output, -+ TextWriter error, -+ bool captureStandardOutput, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.CallCount++; -+ this.Commands.Add( command ); -+ return Task.FromResult( this.myResult ); -+ } -+ -+ } -+ -+ private sealed class RecordingAuxiliaryFileCapability : SedCommand.ISedAuxiliaryFileCapability { -+ -+ public Dictionary ReadFiles { -+ get; -+ } = new( StringComparer.Ordinal ); -+ -+ public Dictionary WrittenFiles { -+ get; -+ } = new( StringComparer.Ordinal ); -+ -+ public ValueTask OpenReadAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ if ( !this.ReadFiles.TryGetValue( path, out var contents ) ) { -+ throw new FileNotFoundException( "No injected auxiliary file exists.", path ); -+ } -+ return ValueTask.FromResult( new MemoryStream( contents, writable: false ) ); -+ } -+ -+ public ValueTask OpenWriteAsync( -+ string path, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ var stream = new MemoryStream(); -+ this.WrittenFiles[ path ] = stream; -+ return ValueTask.FromResult( stream ); -+ } -+ -+ } -+ -+ private sealed class RecordingInPlaceEditor : SedCommand.IInPlaceEditor { -+ -+ public List Requests { -+ get; -+ } = new(); -+ -+ public Task EditAsync( -+ SedCommand.SedInPlaceEditRequest request, -+ Func> transformAsync, -+ CancellationToken cancellationToken -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.Requests.Add( request ); -+ return Task.FromResult( -+ new SedCommand.ExecutionResult( quit: false, exitCode: 0 ) -+ ); -+ } -+ -+ } -+ -+} -diff --git a/tests/Sed.Tests/src/SedRecordAndTextSemanticsTests.cs b/tests/Sed.Tests/src/SedRecordAndTextSemanticsTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..399585530e34c6b2b07fa3767d9a45954a62f3ad ---- /dev/null -+++ b/tests/Sed.Tests/src/SedRecordAndTextSemanticsTests.cs -@@ -0,0 +1,523 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Globalization; -+using System.Reflection; -+using System.Text; -+using SedCommand = Icod.LineEditor.Sed.Command; -+using Xunit; -+ -+/// Verifies the byte-preserving record and text contracts introduced by LE4. -+[Collection( "Sed environment" )] -+public sealed class SedRecordAndTextSemanticsTests { -+ -+ /// Verifies that LF framing preserves carriage returns as ordinary data. -+ [Theory] -+ [InlineData( "one\r\ntwo\r\n" )] -+ [InlineData( "one\rtwo\n" )] -+ [InlineData( "\n\n" )] -+ public async Task LineFeedModePreservesRecordBytes( -+ string input -+ ) { -+ var result = await RunAsync( -+ new string[] { string.Empty }, -+ input -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( input, result.Output ); -+ } -+ -+ /// Verifies that output framing is independent of the host writer newline. -+ [Fact] -+ public async Task OutputUsesExplicitLineFeedRatherThanWriterNewLine() { -+ using var output = new StringWriter { NewLine = "\r\n" }; -+ using var error = new StringWriter { NewLine = "\r\n" }; -+ var exitCode = await SedCommand.RunAsync( -+ new string[] { string.Empty }, -+ new StringReader( "alpha\n" ), -+ output, -+ error -+ ); -+ -+ Assert.Equal( 0, exitCode ); -+ Assert.Equal( "alpha\n", output.ToString() ); -+ } -+ -+ /// Verifies that NUL framing preserves a final unterminated record. -+ [Fact] -+ public async Task NullDataPreservesTerminationMetadata() { -+ var result = await RunAsync( -+ new string[] { "-z", string.Empty }, -+ "one\0two" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "one\0two", result.Output ); -+ } -+ -+ /// Verifies that empty input produces no synthetic record. -+ [Fact] -+ public async Task EmptyInputProducesNoRecord() { -+ var result = await RunAsync( -+ new string[] { string.Empty }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Empty( result.Output ); -+ } -+ -+ /// Verifies that N at end of input still completes the current automatic-print cycle. -+ [Fact] -+ public async Task AppendNextAtEndPreservesCurrentRecord() { -+ var result = await RunAsync( -+ new string[] { "N" }, -+ "alpha" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "alpha", result.Output ); -+ } -+ -+ /// Verifies that multiline pattern space inherits the last contributing record's termination. -+ [Fact] -+ public async Task MultilinePatternSpaceRetainsFinalTermination() { -+ var result = await RunAsync( -+ new string[] { "-n", "N;p" }, -+ "one\ntwo" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "one\ntwo", result.Output ); -+ } -+ -+ /// Verifies that hold-space growth retains the selected pattern-space termination state. -+ [Fact] -+ public async Task HoldSpaceGrowthRetainsTermination() { -+ var result = await RunAsync( -+ new string[] { "-n", "H;g;p" }, -+ "alpha" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "\nalpha", result.Output ); -+ } -+ -+ /// Verifies GNU separation between consecutive outputs after an unterminated record. -+ [Theory] -+ [InlineData( "-n", "p;p", "alpha\nalpha" )] -+ [InlineData( "-n", "p;=", "alpha\n1\n" )] -+ [InlineData( "", "a appended", "alpha\nappended\n" )] -+ public async Task LaterOutputSeparatesAnUnterminatedRecord( -+ string option, -+ string script, -+ string expected -+ ) { -+ var arguments = string.IsNullOrEmpty( option ) -+ ? new string[] { script } -+ : new string[] { option, script } -+ ; -+ var result = await RunAsync( arguments, "alpha" ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( expected, result.Output ); -+ } -+ -+ /// Verifies that NUL framing separates repeated output after an unterminated record. -+ [Fact] -+ public async Task NullDataSeparatesRepeatedUnterminatedOutput() { -+ var result = await RunAsync( -+ new string[] { "-z", "-n", "p;p" }, -+ "alpha" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "alpha\0alpha", result.Output ); -+ } -+ -+ /// Verifies that separate-file mode retains output framing across input boundaries. -+ [Fact] -+ public async Task SeparateFilesShareOutputTerminationState() { -+ var firstPath = CreateTemporaryPath(); -+ var secondPath = CreateTemporaryPath(); -+ try { -+ await File.WriteAllTextAsync( firstPath, "one" ); -+ await File.WriteAllTextAsync( secondPath, "two" ); -+ var result = await RunAsync( -+ new string[] { "-s", string.Empty, firstPath, secondPath }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "one\ntwo", result.Output ); -+ } finally { -+ File.Delete( firstPath ); -+ File.Delete( secondPath ); -+ } -+ } -+ -+ /// Verifies that end-of-file next commands finish one separate input without terminating later files. -+ [Theory] -+ [InlineData( "n" )] -+ [InlineData( "N" )] -+ public async Task SeparateFilesContinueAfterNextCommandReachesEndOfFile( -+ string script -+ ) { -+ var firstPath = CreateTemporaryPath(); -+ var secondPath = CreateTemporaryPath(); -+ try { -+ await File.WriteAllTextAsync( firstPath, "one\n" ); -+ await File.WriteAllTextAsync( secondPath, "two" ); -+ var result = await RunAsync( -+ new string[] { "-s", script, firstPath, secondPath }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "one\ntwo", result.Output ); -+ } finally { -+ File.Delete( firstPath ); -+ File.Delete( secondPath ); -+ } -+ } -+ -+ /// Verifies that in-place processing continues after a next command reaches one file's end. -+ [Fact] -+ public async Task InPlaceFilesContinueAfterNextCommandReachesEndOfFile() { -+ var firstPath = CreateTemporaryPath(); -+ var secondPath = CreateTemporaryPath(); -+ try { -+ await File.WriteAllTextAsync( firstPath, "one" ); -+ await File.WriteAllTextAsync( secondPath, "two" ); -+ var result = await RunAsync( -+ new string[] { "-i", "s/o/O/;n", firstPath, secondPath }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "One", await File.ReadAllTextAsync( firstPath ) ); -+ Assert.Equal( "twO", await File.ReadAllTextAsync( secondPath ) ); -+ } finally { -+ File.Delete( firstPath ); -+ File.Delete( secondPath ); -+ } -+ } -+ -+ /// Verifies P termination for an internal line and for a final unterminated record. -+ [Theory] -+ [InlineData( "one\ntwo", "N;P", "one\n" )] -+ [InlineData( "one", "P", "one" )] -+ public async Task PrintFirstUsesLogicalLineTermination( -+ string input, -+ string script, -+ string expected -+ ) { -+ var result = await RunAsync( new string[] { "-n", script }, input ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( expected, result.Output ); -+ } -+ -+ /// Verifies that NUL mode uses NUL for multiline pattern-space operations. -+ [Fact] -+ public async Task NullDataUsesNulAsThePatternSpaceSeparator() { -+ var printFirst = await RunAsync( -+ new string[] { "-z", "-n", "N;P" }, -+ "one\0two" -+ ); -+ var hold = await RunAsync( -+ new string[] { "-z", "-n", "H;g;p" }, -+ "alpha" -+ ); -+ -+ Assert.Equal( 0, printFirst.ExitCode ); -+ Assert.Equal( "one\0", printFirst.Output ); -+ Assert.Equal( 0, hold.ExitCode ); -+ Assert.Equal( "\0alpha", hold.Output ); -+ } -+ -+ /// Verifies that D removes the first NUL-delimited portion of pattern space. -+ [Fact] -+ public async Task NullDataDeleteFirstUsesNulAsTheInternalSeparator() { -+ var result = await RunAsync( -+ new string[] { "-z", "-n", "N;D" }, -+ "one\0two" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Empty( result.Output ); -+ } -+ -+ /// Verifies that W writes the first NUL-delimited portion with explicit termination. -+ [Fact] -+ public async Task NullDataWriteFirstUsesNulAsTheInternalSeparator() { -+ var path = CreateTemporaryPath(); -+ try { -+ var result = await RunAsync( -+ new string[] { "-z", "-n", $"N;W {path}" }, -+ "one\0two" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Empty( result.Output ); -+ Assert.Equal( "one\0", await File.ReadAllTextAsync( path ) ); -+ } finally { -+ File.Delete( path ); -+ } -+ } -+ -+ /// Verifies GNU list rendering for an internal NUL record separator. -+ [Fact] -+ public async Task NullDataListRendersAnInternalSeparatorAsOctal() { -+ var result = await RunAsync( -+ new string[] { "-z", "-n", "N;l" }, -+ "a\0b\0" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "a\\000b$\0", result.Output ); -+ } -+ -+ /// Verifies that multiline anchors use NUL, not embedded line feed, in NUL mode. -+ [Fact] -+ public async Task NullDataMultilineAnchorsUseTheConfiguredSeparator() { -+ var matchesNulBoundary = await RunAsync( -+ new string[] { "-z", "-n", "N;/^b/Mp" }, -+ "a\0b\0" -+ ); -+ var ignoresEmbeddedLineFeed = await RunAsync( -+ new string[] { "-z", "-n", "N;/^x/Mp" }, -+ "a\nx\0b\0" -+ ); -+ -+ Assert.Equal( 0, matchesNulBoundary.ExitCode ); -+ Assert.Equal( "a\0b\0", matchesNulBoundary.Output ); -+ Assert.Equal( 0, ignoresEmbeddedLineFeed.ExitCode ); -+ Assert.Empty( ignoresEmbeddedLineFeed.Output ); -+ } -+ -+ /// Verifies GNU dot behavior with NUL data and the multiline modifier. -+ [Fact] -+ public async Task NullDataDotMatchesNulExceptWhenItIsAMultilineBoundary() { -+ var ordinary = await RunAsync( -+ new string[] { "-z", "N;s/./X/g" }, -+ "a\0b\0" -+ ); -+ var multiline = await RunAsync( -+ new string[] { "-z", "N;s/./X/gM" }, -+ "a\0b\0" -+ ); -+ -+ Assert.Equal( 0, ordinary.ExitCode ); -+ Assert.Equal( "XXX\0", ordinary.Output ); -+ Assert.Equal( 0, multiline.ExitCode ); -+ Assert.Equal( "X\0X\0", multiline.Output ); -+ } -+ -+ /// Verifies that a large logical record is accepted without materializing unrelated input records. -+ [Fact] -+ public async Task LargeRecordRemainsARecord() { -+ var input = new string( 'a', 1_048_576 ) + "\nsmall\n"; -+ var result = await RunAsync( -+ new string[] { "s/^a/A/" }, -+ input -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( input.Length, result.Output.Length ); -+ Assert.Equal( 'A', result.Output[ 0 ] ); -+ Assert.EndsWith( "\nsmall\n", result.Output ); -+ } -+ -+ /// Verifies that the executable byte-stream path preserves malformed UTF-8 on standard input and output. -+ [Fact] -+ public async Task RawStreamPathPreservesMalformedUtf8() { -+ var previous = SetLocale( "C.UTF-8" ); -+ try { -+ var runStream = typeof( SedCommand ).GetMethod( -+ "RunStreamAsync", -+ BindingFlags.NonPublic | BindingFlags.Static -+ ); -+ Assert.NotNull( runStream ); -+ using var input = new MemoryStream( -+ new byte[] { (byte)'a', 0x80, (byte)'b' } -+ ); -+ using var output = new MemoryStream(); -+ using var error = new StringWriter { NewLine = "\n" }; -+ var operation = Assert.IsType>( -+ runStream!.Invoke( -+ null, -+ new object[] { -+ new string[] { "s/a/A/" }, -+ input, -+ output, -+ error, -+ CancellationToken.None -+ } -+ ) -+ ); -+ -+ Assert.Equal( 0, await operation ); -+ Assert.Equal( -+ new byte[] { (byte)'A', 0x80, (byte)'b' }, -+ output.ToArray() -+ ); -+ Assert.Empty( error.ToString() ); -+ } finally { -+ RestoreLocale( previous ); -+ } -+ } -+ -+ /// Verifies deterministic preservation of malformed UTF-8 during in-place replacement. -+ [Fact] -+ public async Task InvalidUtf8BytesRoundTripThroughInPlaceEditing() { -+ var path = CreateTemporaryPath(); -+ var previous = SetLocale( "C.UTF-8" ); -+ try { -+ await File.WriteAllBytesAsync( -+ path, -+ new byte[] { (byte)'a', 0x80, (byte)'b' } -+ ); -+ var result = await RunAsync( -+ new string[] { "-i", "s/a/A/", path }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( -+ new byte[] { (byte)'A', 0x80, (byte)'b' }, -+ await File.ReadAllBytesAsync( path ) -+ ); -+ } finally { -+ RestoreLocale( previous ); -+ File.Delete( path ); -+ } -+ } -+ -+ /// Verifies the explicit C-byte and UTF-8 locale profiles. -+ [Fact] -+ public async Task LocaleSelectsByteOrUtf8TextSemantics() { -+ var bytePath = CreateTemporaryPath(); -+ var utf8Path = CreateTemporaryPath(); -+ var previous = CaptureLocale(); -+ var previousCulture = CultureInfo.CurrentCulture; -+ var previousUiCulture = CultureInfo.CurrentUICulture; -+ try { -+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; -+ CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; -+ var source = new byte[] { 0xC3, 0xA9, (byte)'\n' }; -+ await File.WriteAllBytesAsync( bytePath, source ); -+ await File.WriteAllBytesAsync( utf8Path, source ); -+ -+ SetLocale( "C" ); -+ var byteResult = await RunAsync( -+ new string[] { "-i", "s/[[:alpha:]]/X/g", bytePath }, -+ string.Empty -+ ); -+ -+ SetLocale( "C.UTF-8" ); -+ var utf8Result = await RunAsync( -+ new string[] { "-i", "s/[[:alpha:]]/X/g", utf8Path }, -+ string.Empty -+ ); -+ -+ Assert.Equal( 0, byteResult.ExitCode ); -+ Assert.Equal( 0, utf8Result.ExitCode ); -+ Assert.Equal( source, await File.ReadAllBytesAsync( bytePath ) ); -+ Assert.Equal( new byte[] { (byte)'X', (byte)'\n' }, await File.ReadAllBytesAsync( utf8Path ) ); -+ } finally { -+ CultureInfo.CurrentCulture = previousCulture; -+ CultureInfo.CurrentUICulture = previousUiCulture; -+ RestoreLocale( previous ); -+ File.Delete( bytePath ); -+ File.Delete( utf8Path ); -+ } -+ } -+ -+ /// Verifies that the private LE4 record model retains all required metadata. -+ [Fact] -+ public void RecordModelContainsRequiredMetadata() { -+ var recordType = typeof( SedCommand ).GetNestedType( -+ "SedInputRecord", -+ BindingFlags.NonPublic -+ ); -+ -+ Assert.NotNull( recordType ); -+ var properties = recordType!.GetProperties( -+ BindingFlags.Instance | BindingFlags.Public -+ ).Select( property => property.Name ).ToHashSet( StringComparer.Ordinal ); -+ Assert.Contains( "Bytes", properties ); -+ Assert.Contains( "Text", properties ); -+ Assert.Contains( "Source", properties ); -+ Assert.Contains( "AggregateRecordNumber", properties ); -+ Assert.Contains( "SourceRecordNumber", properties ); -+ Assert.Contains( "SeparatorKind", properties ); -+ Assert.Contains( "IsTerminated", properties ); -+ } -+ -+ private static string CreateTemporaryPath() { -+ return System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $"icod-sed-le4-{Guid.NewGuid():N}.dat" -+ ); -+ } -+ -+ private static LocaleValues CaptureLocale() { -+ return new LocaleValues( -+ Environment.GetEnvironmentVariable( "LC_ALL" ), -+ Environment.GetEnvironmentVariable( "LC_CTYPE" ), -+ Environment.GetEnvironmentVariable( "LANG" ) -+ ); -+ } -+ -+ private static LocaleValues SetLocale( -+ string name -+ ) { -+ var previous = CaptureLocale(); -+ Environment.SetEnvironmentVariable( "LC_ALL", name ); -+ Environment.SetEnvironmentVariable( "LC_CTYPE", null ); -+ Environment.SetEnvironmentVariable( "LANG", null ); -+ return previous; -+ } -+ -+ private static void RestoreLocale( -+ LocaleValues values -+ ) { -+ Environment.SetEnvironmentVariable( "LC_ALL", values.LcAll ); -+ Environment.SetEnvironmentVariable( "LC_CTYPE", values.LcCtype ); -+ Environment.SetEnvironmentVariable( "LANG", values.Lang ); -+ } -+ -+ private static async Task RunAsync( -+ string[] args, -+ string input -+ ) { -+ using var output = new StringWriter { NewLine = "\n" }; -+ using var error = new StringWriter { NewLine = "\n" }; -+ var exitCode = await SedCommand.RunAsync( -+ args, -+ new StringReader( input ), -+ output, -+ error -+ ); -+ return new CommandResult( exitCode, output.ToString(), error.ToString() ); -+ } -+ -+ private readonly record struct CommandResult( -+ int ExitCode, -+ string Output, -+ string Error -+ ); -+ -+ private readonly record struct LocaleValues( -+ string? LcAll, -+ string? LcCtype, -+ string? Lang -+ ); -+ -+} -+ -+/// Serializes tests that modify the process text-locale environment. -+[CollectionDefinition( "Sed environment", DisableParallelization = true )] -+public sealed class SedEnvironmentCollection { -+} -diff --git a/tests/Sed.Tests/src/SedRegularExpressionMigrationTests.cs b/tests/Sed.Tests/src/SedRegularExpressionMigrationTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..9408e3fded329ed82fd522ea228e0053e6f9141f ---- /dev/null -+++ b/tests/Sed.Tests/src/SedRegularExpressionMigrationTests.cs -@@ -0,0 +1,272 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Globalization; -+using SedCommand = Icod.LineEditor.Sed.Command; -+using Xunit; -+ -+/// -+/// Verifies the Phase LE3 migration from the private .NET translator to the Shared GNU regular-expression provider. -+/// -+[Collection( "Sed environment" )] -+public sealed class SedRegularExpressionMigrationTests { -+ -+ /// -+ /// Exercises a GNU Sed differential corpus whose expected outputs were established with GNU sed 4.10. -+ /// -+ /// An optional syntax-selection option. -+ /// The Sed program. -+ /// The source text. -+ /// The expected edited text. -+ [Theory] -+ [InlineData( "", "s/^\\(a*\\)\\(b*\\)$/\\2-\\1/", "aaabb\n", "bb-aaa\n" )] -+ [InlineData( "-E", "s/(a|ab)/X/", "ab\n", "X\n" )] -+ [InlineData( "", "s/x*/X/g", "abc\n", "XaXbXcX\n" )] -+ [InlineData( "", "s/a*/X/g", "ab\n", "XbX\n" )] -+ [InlineData( "", "s/b*/X/g", "ab\n", "XaX\n" )] -+ [InlineData( "", "s/[a-z]*/X/g", "abc\n", "X\n" )] -+ public async Task GnuSedDifferentialCorpusMatches( -+ string option, -+ string script, -+ string input, -+ string expected -+ ) { -+ var args = string.IsNullOrEmpty( option ) -+ ? new string[] { script } -+ : new string[] { option, script } -+ ; -+ var result = await RunAsync( -+ args, -+ input -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( expected, result.Output ); -+ Assert.Equal( string.Empty, result.Error ); -+ } -+ -+ /// -+ /// Verifies that address modifiers are compiled once and retained when an empty substitution reuses the expression. -+ /// -+ [Fact] -+ public async Task EmptyExpressionReusesCompiledAddressIncludingModifiers() { -+ var result = await RunAsync( -+ new string[] { "-n", "/ALPHA/I{s//X/;p;}" }, -+ "alpha\nbeta\n" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "X\n", result.Output ); -+ } -+ -+ /// -+ /// Verifies GNU Sed escape processing before BRE/ERE parsing. -+ /// -+ /// The Sed substitution program. -+ /// The source text. -+ /// The expected edited text. -+ [Theory] -+ [InlineData( @"s/\t/X/", "\t\n", "X\n" )] -+ [InlineData( @"s/\d065/X/", "A\n", "X\n" )] -+ [InlineData( @"s/\o101/X/", "A\n", "X\n" )] -+ [InlineData( @"s/\x41/X/", "A\n", "X\n" )] -+ [InlineData( @"s/\cA/X/", "\u0001\n", "X\n" )] -+ [InlineData( @"s/\x5ba\x5d/X/", "a\n", "X\n" )] -+ public async Task GnuSedEscapesAreExpandedBeforeRegularExpressionParsing( -+ string script, -+ string input, -+ string expected -+ ) { -+ var result = await RunAsync( -+ new string[] { script }, -+ input -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( expected, result.Output ); -+ Assert.Equal( string.Empty, result.Error ); -+ } -+ -+ /// -+ /// Verifies that the GNU newline escape can match embedded pattern-space separators. -+ /// -+ [Fact] -+ public async Task NewlineEscapeMatchesEmbeddedPatternSpaceSeparator() { -+ var result = await RunAsync( -+ new string[] { "-n", @"N;s/^\(.*\)\n\1$/same/p" }, -+ "repeat\nrepeat\n" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "same\n", result.Output ); -+ Assert.Equal( string.Empty, result.Error ); -+ } -+ -+ /// -+ /// Verifies that strict POSIX mode disables GNU escape processing only inside raw bracket expressions. -+ /// -+ [Fact] -+ public async Task PosixModeDisablesGnuEscapesInsideRawBracketExpressions() { -+ var defaultResult = await RunAsync( -+ new string[] { @"s/[\t]/X/" }, -+ "\t\n" -+ ); -+ var posixBracketResult = await RunAsync( -+ new string[] { "--posix", @"s/[\t]/X/" }, -+ "\t\n" -+ ); -+ var posixOutsideResult = await RunAsync( -+ new string[] { "--posix", @"s/\t/X/" }, -+ "\t\n" -+ ); -+ var generatedBracketResult = await RunAsync( -+ new string[] { "--posix", @"s/\x5b\t\x5d/X/" }, -+ "\t\n" -+ ); -+ -+ Assert.Equal( 0, defaultResult.ExitCode ); -+ Assert.Equal( "X\n", defaultResult.Output ); -+ Assert.Equal( 0, posixBracketResult.ExitCode ); -+ Assert.Equal( "\t\n", posixBracketResult.Output ); -+ Assert.Equal( 0, posixOutsideResult.ExitCode ); -+ Assert.Equal( "X\n", posixOutsideResult.Output ); -+ Assert.Equal( 0, generatedBracketResult.ExitCode ); -+ Assert.Equal( "X\n", generatedBracketResult.Output ); -+ } -+ -+ /// -+ /// Verifies GNU Sed's rule that modifiers cannot be attached to an empty expression. -+ /// -+ [Fact] -+ public async Task EmptyExpressionRejectsNewModifiers() { -+ var result = await RunAsync( -+ new string[] { "s/a/A/;s//X/I" }, -+ "a\n" -+ ); -+ -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Contains( "cannot specify modifiers on an empty regular expression", result.Error ); -+ } -+ -+ /// -+ /// Verifies that GNU multiline mode affects anchors inside a multiline pattern space. -+ /// -+ [Fact] -+ public async Task MultilineModifierUsesSharedLineSensitiveMatching() { -+ var result = await RunAsync( -+ new string[] { "-n", "N;s/^two$/X/M;p" }, -+ "one\ntwo\n" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "one\nX\n", result.Output ); -+ } -+ -+ /// -+ /// Verifies that invariant process culture does not override an explicit UTF-8 locale profile. -+ /// -+ [Fact] -+ public async Task InvariantCultureRetainsUtf8LocaleCharacterClasses() { -+ var originalLocale = CaptureLocale(); -+ var originalCulture = CultureInfo.CurrentCulture; -+ var originalUiCulture = CultureInfo.CurrentUICulture; -+ try { -+ SetLocale( "C.UTF-8" ); -+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; -+ CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; -+ var result = await RunAsync( -+ new string[] { "s/[[:alpha:]]/X/g" }, -+ "├⌐A\n" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "XX\n", result.Output ); -+ } finally { -+ CultureInfo.CurrentCulture = originalCulture; -+ CultureInfo.CurrentUICulture = originalUiCulture; -+ RestoreLocale( originalLocale ); -+ } -+ } -+ -+ /// -+ /// Verifies that POSIX mode treats GNU-only escaped BRE operators as literals. -+ /// -+ [Fact] -+ public async Task PosixModeDisablesGnuBasicOperators() { -+ var result = await RunAsync( -+ new string[] { "--posix", @"s/a\+/X/" }, -+ "a+\naaa\n" -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "X\naaa\n", result.Output ); -+ } -+ -+ /// -+ /// Verifies that Shared compile diagnostics are translated into Sed usage diagnostics. -+ /// -+ [Fact] -+ public async Task SharedCompileDiagnosticUsesSedPresentation() { -+ var result = await RunAsync( -+ new string[] { "s/[a-/X/" }, -+ "a\n" -+ ); -+ -+ Assert.Equal( 2, result.ExitCode ); -+ Assert.Contains( "invalid regular expression in substitution", result.Error ); -+ } -+ -+ private static LocaleValues CaptureLocale() { -+ return new LocaleValues( -+ Environment.GetEnvironmentVariable( "LC_ALL" ), -+ Environment.GetEnvironmentVariable( "LC_CTYPE" ), -+ Environment.GetEnvironmentVariable( "LANG" ) -+ ); -+ } -+ private static void SetLocale( -+ string name -+ ) { -+ Environment.SetEnvironmentVariable( "LC_ALL", name ); -+ Environment.SetEnvironmentVariable( "LC_CTYPE", null ); -+ Environment.SetEnvironmentVariable( "LANG", null ); -+ } -+ private static void RestoreLocale( -+ LocaleValues values -+ ) { -+ Environment.SetEnvironmentVariable( "LC_ALL", values.LcAll ); -+ Environment.SetEnvironmentVariable( "LC_CTYPE", values.LcCtype ); -+ Environment.SetEnvironmentVariable( "LANG", values.Lang ); -+ } -+ -+ private static async Task RunAsync( -+ string[] args, -+ string input, -+ CancellationToken cancellationToken = default -+ ) { -+ using var output = new StringWriter { NewLine = "\n" }; -+ using var error = new StringWriter { NewLine = "\n" }; -+ var exitCode = await SedCommand.RunAsync( -+ args, -+ new StringReader( input ), -+ output, -+ error, -+ cancellationToken -+ ); -+ return new CommandResult( -+ exitCode, -+ output.ToString(), -+ error.ToString() -+ ); -+ } -+ -+ private sealed record CommandResult( -+ int ExitCode, -+ string Output, -+ string Error -+ ); -+ private readonly record struct LocaleValues( -+ string? LcAll, -+ string? LcCtype, -+ string? Lang -+ ); -+ -+} -diff --git a/tests/Sed.Tests/src/TransactionalReplacementIntegrationTests.cs b/tests/Sed.Tests/src/TransactionalReplacementIntegrationTests.cs -new file mode 100644 -index 0000000000000000000000000000000000000000..99e4cdf52ead41de591c40e10b9825c15291d1ab ---- /dev/null -+++ b/tests/Sed.Tests/src/TransactionalReplacementIntegrationTests.cs -@@ -0,0 +1,241 @@ -+namespace Icod.LineEditor.Sed.Tests; -+ -+using System.Text; -+using Icod.CommandFramework.FileSystem.TransactionalReplacement; -+using SedCommand = Icod.LineEditor.Sed.Command; -+using Xunit; -+ -+/// Validates the Phase LE10 integration between Sed in-place editing and Completion Gate E6. -+public sealed class TransactionalReplacementIntegrationTests { -+ /// Verifies atomic publication, retained backup policy, and metadata preservation. -+ [Fact] -+ public async Task InPlaceEditPublishesReplacementAndRetainsRequestedBackup() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "input.txt" ); -+ var backupPath = string.Concat( path, ".bak" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ UnixFileMode? originalMode = null; -+ if ( !OperatingSystem.IsWindows() ) { -+ originalMode = UnixFileMode.UserRead -+ | UnixFileMode.UserWrite -+ | UnixFileMode.GroupRead; -+ File.SetUnixFileMode( path, originalMode.Value ); -+ } -+ var injector = new RecordingFailureInjector(); -+ var editor = new SedCommand.SystemInPlaceEditor( -+ SystemTransactionalReplacementFileSystem.Instance, -+ injector -+ ); -+ -+ var result = await editor.EditAsync( -+ new SedCommand.SedInPlaceEditRequest( -+ path, -+ FollowSymlinks: false, -+ BackupSuffix: ".bak" -+ ), -+ WriteResultAsync( "replacement\n" ), -+ CancellationToken.None -+ ); -+ -+ Assert.Equal( 0, result.ExitCode ); -+ Assert.Equal( "replacement\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Equal( "original\n", await File.ReadAllTextAsync( backupPath ) ); -+ if ( originalMode.HasValue ) { -+#pragma warning disable CA1416 -+ Assert.Equal( originalMode.Value, File.GetUnixFileMode( path ) ); -+#pragma warning restore CA1416 -+ } -+ Assert.Equal( -+ new string[] { "input.txt", "input.txt.bak" }, -+ EntryNames( directory.Path ) -+ ); -+ Assert.Contains( TransactionalReplacementStage.WriteTemporary, injector.ObservedStages ); -+ Assert.Contains( TransactionalReplacementStage.PublishBackup, injector.ObservedStages ); -+ Assert.Contains( TransactionalReplacementStage.Commit, injector.ObservedStages ); -+ } -+ -+ /// Verifies restoration of both destination and pre-existing backup after post-commit failure. -+ [Fact] -+ public async Task PostCommitFailureRestoresDestinationAndExistingBackup() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "input.txt" ); -+ var backupPath = string.Concat( path, ".bak" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ await File.WriteAllTextAsync( backupPath, "previous backup\n" ); -+ var injector = new ThrowAtStageFailureInjector( -+ TransactionalReplacementStage.ApplyMetadata -+ ); -+ var editor = new SedCommand.SystemInPlaceEditor( -+ SystemTransactionalReplacementFileSystem.Instance, -+ injector -+ ); -+ -+ await Assert.ThrowsAsync( -+ () => editor.EditAsync( -+ new SedCommand.SedInPlaceEditRequest( -+ path, -+ FollowSymlinks: false, -+ BackupSuffix: ".bak" -+ ), -+ WriteResultAsync( "replacement\n" ), -+ CancellationToken.None -+ ) -+ ); -+ -+ Assert.Equal( "original\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Equal( "previous backup\n", await File.ReadAllTextAsync( backupPath ) ); -+ Assert.Contains( TransactionalReplacementStage.ApplyMetadata, injector.ObservedStages ); -+ Assert.Equal( -+ new string[] { "input.txt", "input.txt.bak" }, -+ EntryNames( directory.Path ) -+ ); -+ } -+ -+ /// Verifies that cancellation leaves the input and directory unchanged. -+ [Fact] -+ public async Task CanceledInPlaceEditPreservesInputAndCleansArtifacts() { -+ using var directory = new TemporaryDirectory(); -+ var path = System.IO.Path.Combine( directory.Path, "input.txt" ); -+ await File.WriteAllTextAsync( path, "original\n" ); -+ using var cancellation = new CancellationTokenSource(); -+ cancellation.Cancel(); -+ var editor = new SedCommand.SystemInPlaceEditor( -+ SystemTransactionalReplacementFileSystem.Instance -+ ); -+ -+ await Assert.ThrowsAnyAsync( -+ () => editor.EditAsync( -+ new SedCommand.SedInPlaceEditRequest( -+ path, -+ FollowSymlinks: false, -+ BackupSuffix: null -+ ), -+ WriteResultAsync( "replacement\n" ), -+ cancellation.Token -+ ) -+ ); -+ -+ Assert.Equal( "original\n", await File.ReadAllTextAsync( path ) ); -+ Assert.Equal( new string[] { "input.txt" }, EntryNames( directory.Path ) ); -+ } -+ -+ /// Verifies the explicit Sed follow-symlinks policy before no-follow E6 planning. -+ [Fact] -+ public async Task FollowSymlinksEditsResolvedTargetWhileDefaultRejectsTerminalLink() { -+ using var directory = new TemporaryDirectory(); -+ var target = System.IO.Path.Combine( directory.Path, "target.txt" ); -+ var link = System.IO.Path.Combine( directory.Path, "link.txt" ); -+ await File.WriteAllTextAsync( target, "target\n" ); -+ try { -+ File.CreateSymbolicLink( link, target ); -+ } catch ( Exception ex ) when ( -+ ex is UnauthorizedAccessException -+ or PlatformNotSupportedException -+ or IOException -+ ) { -+ return; -+ } -+ var editor = new SedCommand.SystemInPlaceEditor( -+ SystemTransactionalReplacementFileSystem.Instance -+ ); -+ -+ await Assert.ThrowsAsync( -+ () => editor.EditAsync( -+ new SedCommand.SedInPlaceEditRequest( -+ link, -+ FollowSymlinks: false, -+ BackupSuffix: null -+ ), -+ WriteResultAsync( "not-followed\n" ), -+ CancellationToken.None -+ ) -+ ); -+ Assert.Equal( "target\n", await File.ReadAllTextAsync( target ) ); -+ -+ await editor.EditAsync( -+ new SedCommand.SedInPlaceEditRequest( -+ link, -+ FollowSymlinks: true, -+ BackupSuffix: null -+ ), -+ WriteResultAsync( "followed\n" ), -+ CancellationToken.None -+ ); -+ -+ Assert.Equal( "followed\n", await File.ReadAllTextAsync( target ) ); -+ Assert.NotNull( new FileInfo( link ).LinkTarget ); -+ } -+ -+ private static Func> WriteResultAsync( -+ string content -+ ) => async ( _, destination, cancellationToken ) => { -+ await destination.WriteAsync( -+ new ReadOnlyMemory( Encoding.UTF8.GetBytes( content ) ), -+ cancellationToken -+ ); -+ return new SedCommand.ExecutionResult( -+ quit: false, -+ exitCode: 0 -+ ); -+ }; -+ -+ private static string[] EntryNames( -+ string directory -+ ) => Directory.EnumerateFileSystemEntries( directory ) -+ .Select( value => System.IO.Path.GetFileName( value ) ?? string.Empty ) -+ .OrderBy( value => value, StringComparer.Ordinal ) -+ .ToArray(); -+ -+ private class RecordingFailureInjector : ITransactionalReplacementFailureInjector { -+ public List ObservedStages { get; } = new(); -+ -+ public virtual ValueTask OnStageAsync( -+ TransactionalReplacementStage stage, -+ TransactionalReplacementArtifact artifact, -+ CancellationToken cancellationToken = default -+ ) { -+ cancellationToken.ThrowIfCancellationRequested(); -+ this.ObservedStages.Add( stage ); -+ return ValueTask.CompletedTask; -+ } -+ } -+ -+ private sealed class ThrowAtStageFailureInjector : RecordingFailureInjector { -+ private readonly TransactionalReplacementStage failureStage; -+ -+ public ThrowAtStageFailureInjector( -+ TransactionalReplacementStage failureStage -+ ) { -+ this.failureStage = failureStage; -+ } -+ -+ public override async ValueTask OnStageAsync( -+ TransactionalReplacementStage stage, -+ TransactionalReplacementArtifact artifact, -+ CancellationToken cancellationToken = default -+ ) { -+ await base.OnStageAsync( stage, artifact, cancellationToken ).ConfigureAwait( false ); -+ if ( this.failureStage == stage ) { -+ throw new IOException( $"Injected failure at {stage}." ); -+ } -+ } -+ } -+ -+ private sealed class TemporaryDirectory : IDisposable { -+ public TemporaryDirectory() { -+ this.Path = System.IO.Path.Combine( -+ System.IO.Path.GetTempPath(), -+ $".icod-sed-le10-{Guid.NewGuid():N}" -+ ); -+ Directory.CreateDirectory( this.Path ); -+ } -+ -+ public string Path { get; } -+ -+ public void Dispose() { -+ if ( Directory.Exists( this.Path ) ) { -+ Directory.Delete( this.Path, recursive: true ); -+ } -+ } -+ } -+} diff --git a/Icod.LineEditor.Ed.Shared/LICENSE b/Icod.LineEditor.Ed.Shared/LICENSE index 43c5fc5..a369315 100644 --- a/Icod.LineEditor.Ed.Shared/LICENSE +++ b/Icod.LineEditor.Ed.Shared/LICENSE @@ -1,165 +1,760 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2025 - - Copyright (C) 2025 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of +GNU LESSER GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the terms +and conditions of version 3 of the GNU General Public License, supplemented by +the additional permissions listed below. + +0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser General +Public License, and the "GNU GPL" refers to version 3 of the GNU General Public +License. + +"The Library" refers to a covered work governed by this License, other than an +Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided by the +Library, but which is not otherwise based on the Library. Defining a subclass +of a class defined by the Library is deemed a mode of using an interface +provided by the Library. + +A "Combined Work" is a work produced by combining or linking an Application +with the Library. The particular version of the Library with which the +Combined Work was made is also called the "Linked Version". + +The "Minimal Corresponding Source" for a Combined Work means the Corresponding +Source for the Combined Work, excluding any source code for portions of the +Combined Work that, considered in isolation, are based on the Application, and +not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the object code +and/or source code for the Application, including any data and utility programs +needed for reproducing the Combined Work from the Application, but excluding +the System Libraries of the Combined Work. + +1. Exception to Section 3 of the GNU GPL. +You may convey a covered work under sections 3 and 4 of this License without +being bound by section 3 of the GNU GPL. + +2. Conveying Modified Versions. +If you modify a copy of the Library, and, in your modifications, a facility +refers to a function or data to be supplied by an Application that uses the +facility (other than as an argument passed when the facility is invoked), then +you may convey a copy of the modified version: + + a) under this License, provided that you make a good faith effort to +ensure that, in the event an Application does not supply the function or data, +the facility still operates, and performs whatever part of its purpose remains +meaningful, or + + b) under the GNU GPL, with none of the additional permissions of this +License applicable to that copy. + +3. Object Code Incorporating Material from Library Header Files. +The object code form of an Application may incorporate material from a header +file that is part of the Library. You may convey such object code under terms +of your choice, provided that, if the incorporated material is not limited to +numerical parameters, data structure layouts and accessors, or small macros, +inline functions and templates (ten or fewer lines in length), you do both of the following: - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. + a) Give prominent notice with each copy of the object code that the +Library is used in it and that the Library and its use are covered by this +License. + + b) Accompany the object code with a copy of the GNU GPL and this license +document. + +4. Combined Works. +You may convey a Combined Work under terms of your choice that, taken together, +effectively do not restrict modification of the portions of the Library +contained in the Combined Work and reverse engineering for debugging such +modifications, if you also do each of the following: + + a) Give prominent notice with each copy of the Combined Work that the +Library is used in it and that the Library and its use are covered by this +License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license +document. + + c) For a Combined Work that displays copyright notices during execution, +include the copyright notice for the Library among these notices, as well as a +reference directing the user to the copies of the GNU GPL and this license +document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this +License, and the Corresponding Application Code in a form suitable for, and +under terms that permit, the user to recombine or relink the Application with a +modified version of the Linked Version to produce a modified Combined Work, in +the manner specified by section 6 of the GNU GPL for conveying Corresponding +Source. + + 1) Use a suitable shared library mechanism for linking with the +Library. A suitable mechanism is one that (a) uses at run time a copy of the +Library already present on the user's computer system, and (b) will operate +properly with a modified version of the Library that is interface-compatible +with the Linked Version. + + e) Provide Installation Information, but only if you would otherwise be +required to provide such information under section 6 of the GNU GPL, and only +to the extent that such information is necessary to install and execute a +modified version of the Combined Work produced by recombining or relinking the +Application with a modified version of the Linked Version. (If you use option +4d0, the Installation Information must accompany the Minimal Corresponding +Source and Corresponding Application Code. If you use option 4d1, you must +provide the Installation Information in the manner specified by section 6 of +the GNU GPL for conveying Corresponding Source.) + +5. Combined Libraries. +You may place library facilities that are a work based on the Library side by +side in a single library together with other library facilities that are not +Applications and are not covered by this License, and convey such a combined +library under terms of your choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based on +the Library, uncombined with any other library facilities, conveyed under the +terms of this License. + + b) Give prominent notice with the combined library that part of it is a +work based on the Library, and explaining where to find the accompanying +uncombined form of the same work. + +6. Revised Versions of the GNU Lesser General Public License. +The Free Software Foundation may publish revised and/or new versions of the GNU +Lesser General Public License from time to time. Such new versions will be +similar in spirit to the present version, but may differ in detail to address +new problems or concerns. + +Each version is given a distinguishing version number. If the Library as you +received it specifies that a certain numbered version of the GNU Lesser General +Public License "or any later version" applies to it, you have the option of +following the terms and conditions either of that published version or of any +later version published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser General Public +License, you may choose any version of the GNU Lesser General Public License +ever published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide whether +future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is permanent +authorization for you to choose that version for the Library. + +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright c 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and +other kinds of works. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, the GNU General +Public License is intended to guarantee your freedom to share and change all +versions of a program--to make sure it remains free software for all its users. +We, the Free Software Foundation, use the GNU General Public License for most +of our software; it applies also to any other work released this way by its +authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom to +distribute copies of free software (and charge for them if you wish), that you +receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can do +these things. + +To protect your rights, we need to prevent others from denying you these rights +or asking you to surrender the rights. Therefore, you have certain +responsibilities if you distribute copies of the software, or if you modify it: +responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for +a fee, you must pass on to the recipients the same freedoms that you received. +You must make sure that they, too, receive or can get the source code. And you +must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert +copyright on the software, and (2) offer you this License giving you legal +permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that +there is no warranty for this free software. For both users' and authors' sake, +the GPL requires that modified versions be marked as changed, so that their +problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified +versions of the software inside them, although the manufacturer can do so. This +is fundamentally incompatible with the aim of protecting users' freedom to +change the software. The systematic pattern of such abuse occurs in the area of +products for individuals to use, which is precisely where it is most +unacceptable. Therefore, we have designed this version of the GPL to prohibit +the practice for those products. If such problems arise substantially in other +domains, we stand ready to extend this provision to those domains in future +versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States +should not allow patents to restrict development and use of software on +general-purpose computers, but in those that do, we wish to avoid the special +danger that patents applied to a free program could make it effectively +proprietary. To prevent this, the GPL assures that patents cannot be used to +render the program non-free. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, +such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact copy. +The resulting work is called a "modified version" of the earlier work or a work +"based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under applicable +copyright law, except executing it on a computer or modifying a private copy. +Propagation includes copying, distribution (with or without modification), +making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to +make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there is +no warranty for the work (except to the extent that warranties are provided), +that licensees may convey the work under this License, and how to view a copy +of this License. If the interface presents a list of user commands or options, +such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces specified +for a particular programming language, one that is widely used among developers +working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a Standard +Interface for which an implementation is available to the public in source code +form. A "Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system (if any) on +which the executable work runs, or a compiler used to produce the work, or an +object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source +code needed to generate, install, and (for an executable work) run the object +code and to modify the work, including scripts to control those activities. +However, it does not include the work's System Libraries, or general-purpose +tools or generally available free programs which are used unmodified in +performing those activities but which are not part of the work. For example, +Corresponding Source includes interface definition files associated with source +files for the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, such as +by intimate data communication or control flow between those subprograms and +other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on +the Program, and are irrevocable provided the stated conditions are met. This +License explicitly affirms your unlimited permission to run the unmodified +Program. The output from running a covered work is covered by this License only +if the output, given its content, constitutes a covered work. This License +acknowledges your rights of fair use or other equivalent, as provided by +copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make modifications +exclusively for you, or provide you with facilities for running those works, +provided that you comply with the terms of this License in conveying all +material for which you do not control copyright. Those thus making or running +the covered works for you must do so exclusively on your behalf, under your +direction and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of the +work as a means of enforcing, against the work's users, your or third parties' +legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, +in any medium, provided that you conspicuously and appropriately publish on +each copy an appropriate copyright notice; keep intact all notices stating that +this License and any non-permissive terms added in accord with section 7 apply +to the code; keep intact all notices of the absence of any warranty; and give +all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may +offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it +from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. + + b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to +anyone who comes into possession of a copy. This License will therefore apply, +along with any applicable section 7 additional terms, to the whole of the work, +and all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display +Appropriate Legal Notices; however, if the Program has interactive interfaces +that do not display Appropriate Legal Notices, your work need not make them do +so. + +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are not +combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation and +its resulting copyright are not used to limit the access or legal rights of the +compilation's users beyond what the individual works permit. Inclusion of a +covered work in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 +and 5, provided that you also convey the machine-readable Corresponding Source +under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by the Corresponding +Source fixed on a durable physical medium customarily used for software +interchange. + + b) Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by a written offer, +valid for at least three years and valid for as long as you offer spare parts +or customer support for that product model, to give anyone who possesses the +object code either (1) a copy of the Corresponding Source for all the software +in the product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code with +such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place +(gratis or for a charge), and offer equivalent access to the Corresponding +Source in the same way through the same place at no further charge. You need +not require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain clear +directions next to the object code saying where to find the Corresponding +Source. Regardless of what server hosts the Corresponding Source, you remain +obligated to ensure that it is available for as long as needed to satisfy these +requirements. + + e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the +Corresponding Source as a System Library, need not be included in conveying the +object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall be +resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected to use, +the product. A product is a consumer product regardless of whether the product +has substantial commercial, industrial or non-consumer uses, unless such uses +represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of a +transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed under +this section must be accompanied by the Installation Information. But this +requirement does not apply if neither you nor any third party retains the +ability to install modified object code on the User Product (for example, the +work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for a +work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may be +denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for communication +across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord +with this section must be in a format that is publicly documented (and with an +implementation available to the public in source code form), and must require +no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by +making exceptions from one or more of its conditions. Additional permissions +that are applicable to the entire Program shall be treated as though they were +included in this License, to the extent that they are valid under applicable +law. If additional permissions apply only to part of the Program, that part may +be used separately under those permissions, but the entire Program remains +governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate copyright +permission. + +Notwithstanding any other provision of this License, for material you add to a +covered work, you may (if authorized by the copyright holders of that material) +supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms +of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed by +works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or +requiring that modified versions of such material be marked in reasonable ways +as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or +authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade +names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" +within the meaning of section 10. If the Program as you received it, or any +part of it, contains a notice stating that it is governed by this License along +with a term that is a further restriction, you may remove that term. If a +license document contains a further restriction but permits relicensing or +conveying under this License, you may add to a covered work material governed +by the terms of that license document, provided that the further restriction +does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, +in the relevant source files, a statement of the additional terms that apply to +those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a +separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including any +patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of violation +of this License (for any work) from that copyright holder, and you cure the +violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise does +not require acceptance. However, nothing other than this License grants you +permission to propagate or modify any covered work. These actions infringe +copyright if you do not accept this License. Therefore, by modifying or +propagating a covered work, you indicate your acceptance of this License to do +so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who receives +a copy of the work also receives whatever licenses to the work the party's +predecessor in interest had or could give under the previous paragraph, plus a +right to possession of the Corresponding Source of the work from the +predecessor in interest, if the predecessor has it or can get it with +reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under this +License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License of +the Program or a work on which the Program is based. The work thus licensed is +called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter acquired, +that would be infringed by some manner, permitted by this License, of making, +using, or selling its contributor version, but do not include claims that would +be infringed only as a consequence of further modification of the contributor +version. For purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents of +its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement +or commitment, however denominated, not to enforce a patent (such as an express +permission to practice a patent or covenant not to sue for patent +infringement). To "grant" such a patent license to a party means to make such +an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or (3) +arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work in a +country, would infringe one or more identifiable patents in that country that +you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you +convey, or propagate by procuring conveyance of, a covered work, and grant a +patent license to some of the parties receiving the covered work authorizing +them to use, propagate, modify or convey a specific copy of the covered work, +then the patent license you grant is automatically extended to all recipients +of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of +its coverage, prohibits the exercise of, or is conditioned on the non-exercise +of one or more of the rights that are specifically granted under this License. +You may not convey a covered work if you are a party to an arrangement with a +third party that is in the business of distributing software, under which you +make payment to the third party based on the extent of your activity of +conveying the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by you (or +copies made from those copies), or (b) primarily for and in connection with +specific products or compilations that contain the covered work, unless you +entered into that arrangement, or that patent license was granted, prior to 28 +March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied +license or other defenses to infringement that may otherwise be available to +you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work so +as to satisfy simultaneously your obligations under this License and any other +pertinent obligations, then as a consequence you may not convey it at all. For +example, if you agree to terms that obligate you to collect a royalty for +further conveying from those to whom you convey the Program, the only way you +could satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU Affero General Public License into a single combined work, and to convey +the resulting work. The terms of this License will continue to apply to the +part which is the covered work, but the special requirements of the GNU Affero +General Public License, section 13, concerning interaction through a network +will apply to the combination as such. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU +General Public License from time to time. Such new versions will be similar in +spirit to the present version, but may differ in detail to address new problems +or concerns. + +Each version is given a distinguishing version number. If the Program specifies +that a certain numbered version of the GNU General Public License "or any later +version" applies to it, you have the option of following the terms and +conditions either of that numbered version or of any later version published by +the Free Software Foundation. If the Program does not specify a version number +of the GNU General Public License, you may choose any version ever published by +the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU General Public License can be used, that proxy's public statement of +acceptance of a version permanently authorizes you to choose that version for +the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER +PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER +EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE +QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY +COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS +PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, +INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE +THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED +INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE +PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY +HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot +be given local legal effect according to their terms, reviewing courts shall +apply local law that most closely approximates an absolute waiver of all civil +liability in connection with the Program, unless a warranty or assumption of +liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) any +later version. + + This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + + You should have received a copy of the GNU General Public License along +with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like +this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it under +certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands might +be different; for a GUI interface, you would use an "about box". + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU GPL, see +. + +The GNU General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may consider +it more useful to permit linking proprietary applications with the library. If +this is what you want to do, use the GNU Lesser General Public License instead +of this License. But first, please read +. From e2dde61bc39b26ae2d3f6ae309bfd39d7dec3896 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:47:26 -0400 Subject: [PATCH 22/29] Centralize repository version at 1.1.0 --- Directory.Build.props | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e74a1d5 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + 1.1.0 + $(VersionPrefix) + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + From 4c478826732a2ebd19137af2b069475ef3a234fd Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:48:18 -0400 Subject: [PATCH 23/29] Document centralized 1.1.0 version --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 192a626..164a1de 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ It calls the managed command implementations directly and does not spawn the sta ## Installation and distribution -The distribution router is published as the .NET tool package `Icod.LineEditor.Tools`. Install the current published version with: +The distribution router is published as the .NET tool package `Icod.LineEditor.Tools`. Install version `1.1.0` with: ```text -dotnet tool install --global Icod.LineEditor.Tools +dotnet tool install --global Icod.LineEditor.Tools --version 1.1.0 ``` The installed command is: @@ -171,6 +171,12 @@ dotnet test Icod.LineEditor.sln -c Staging --no-build --no-restore The solution defines `Debug`, `Staging`, and `Release` configurations. Release builds treat compiler warnings as errors except for documentation warning `CS1591`. +## Versioning + +Repository versioning is centralized in the root [`Directory.Build.props`](Directory.Build.props). `VersionPrefix` is the single authoritative release-version literal and is currently `1.1.0`. `Version`, `PackageVersion`, `AssemblyVersion`, and `FileVersion` are derived from it for projects in the repository. + +For a tagged release, the `v` tag must agree with the generated NuGet package version. The release workflow selects packages by their actual nuspec version, so a mismatched tag cannot silently publish a differently versioned package. + ## Continuous integration and release The repository follows the canonical `uniblab/.github` lifecycle: @@ -189,6 +195,7 @@ See [`packaging/README.md`](packaging/README.md) for the complete build, validat ```text Icod.LineEditor/ +├── Directory.Build.props centralized repository version ├── Icod.LineEditor.Ed.Shared/ mutable Ed/Red engine ├── ed/ standard line editor ├── red/ restricted line editor From c87c4ef4c44121de0087dd52a51e8abf1c3c98ca Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 04:48:40 -0400 Subject: [PATCH 24/29] Document centralized release version --- packaging/README.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packaging/README.md b/packaging/README.md index 41dd501..9b0cd04 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -27,6 +27,25 @@ The router project identity is `Icod.LineEditor.Router`; its assembly and execut The `Icod.LineEditor.Tools` package uses the repository root `README.md` as `PackageReadmeFile`. The router-specific `lineeditor/README.md` remains repository documentation and is not the NuGet package README. +## Version contract + +Repository versioning is centralized in the root `Directory.Build.props`: + +```xml +1.1.0 +``` + +`VersionPrefix` is the single authoritative release-version literal. The repository derives: + +```text +Version = 1.1.0 +PackageVersion = 1.1.0 +AssemblyVersion = 1.1.0.0 +FileVersion = 1.1.0.0 +``` + +Production projects inherit these values unless a future project has an explicit reason to override them. Release tags must agree with the generated package version; `SelectReleasePackages.ps1` verifies the actual nuspec version before publication. + ## Shared scripts ### `RepositoryTools.psm1` @@ -168,11 +187,11 @@ That `README.md` is sourced from the repository root and packed at the NuGet pac ## Release checklist -Before pushing a release tag: +Before pushing release tag `v1.1.0`: -1. confirm the intended package/version metadata is present in MSBuild; -2. confirm `lineeditor --version` and command documentation are consistent with that release; -3. confirm the root README installation examples and package identity are current; +1. confirm `Directory.Build.props` still declares `VersionPrefix` `1.1.0`; +2. confirm `lineeditor --version` reports `1.1.0` from assembly informational version metadata; +3. confirm the root README installation example and package identity are current; 4. confirm PR Staging validation is green; 5. merge to `main` and require the six-runner Release validation to pass; and -6. only then push the matching `v` tag. +6. only then push tag `v1.1.0`. From 070bd5ae9c1fe1b8fda91d8df6f8d6c45f1f656b Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 05:00:17 -0400 Subject: [PATCH 25/29] Restrict NuGet packaging to router --- Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj b/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj index 991b17b..b691a5f 100644 --- a/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj +++ b/Icod.LineEditor.Ed.Shared/Icod.LineEditor.Ed.Shared.csproj @@ -6,6 +6,7 @@ enable enable true + false ..\bin\$(Configuration)\ Icod.LineEditor.Ed.Shared Icod.LineEditor.Ed From 2ba43eba4ecdf87024f23b511465b91c036cc3a8 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 05:00:38 -0400 Subject: [PATCH 26/29] Restrict NuGet packaging to router --- ed/Icod.LineEditor.Ed.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/ed/Icod.LineEditor.Ed.csproj b/ed/Icod.LineEditor.Ed.csproj index 5655c16..3b95893 100644 --- a/ed/Icod.LineEditor.Ed.csproj +++ b/ed/Icod.LineEditor.Ed.csproj @@ -7,6 +7,7 @@ enable enable true + false ..\bin\$(Configuration)\ ed Icod.LineEditor.Ed From 14fc339e562a047eef9e45c93060f89f8bfff7b8 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 05:00:59 -0400 Subject: [PATCH 27/29] Restrict NuGet packaging to router --- red/Icod.LineEditor.Red.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/red/Icod.LineEditor.Red.csproj b/red/Icod.LineEditor.Red.csproj index 15fdabc..63b0408 100644 --- a/red/Icod.LineEditor.Red.csproj +++ b/red/Icod.LineEditor.Red.csproj @@ -7,6 +7,7 @@ enable enable true + false ..\bin\$(Configuration)\ red Icod.LineEditor.Red From c65bf15794d1f3adf40352ac152822127b6a68ea Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 05:01:15 -0400 Subject: [PATCH 28/29] Restrict NuGet packaging to router --- sed/Icod.LineEditor.Sed.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/sed/Icod.LineEditor.Sed.csproj b/sed/Icod.LineEditor.Sed.csproj index 869bd07..7062d92 100644 --- a/sed/Icod.LineEditor.Sed.csproj +++ b/sed/Icod.LineEditor.Sed.csproj @@ -7,6 +7,7 @@ enable enable true + false ..\bin\$(Configuration)\ sed Icod.LineEditor.Sed From 816378f5a1a7b6b880474321eb03f69b658558b6 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Thu, 3 Sep 2026 05:01:51 -0400 Subject: [PATCH 29/29] Document single package boundary --- packaging/README.md | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/packaging/README.md b/packaging/README.md index 9b0cd04..8388cde 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -27,6 +27,28 @@ The router project identity is `Icod.LineEditor.Router`; its assembly and execut The `Icod.LineEditor.Tools` package uses the repository root `README.md` as `PackageReadmeFile`. The router-specific `lineeditor/README.md` remains repository documentation and is not the NuGet package README. +## NuGet package boundary + +Only the router project is an intended NuGet package producer. + +The repository explicitly declares: + +```text +Icod.LineEditor.Ed.Shared IsPackable=false +ed IsPackable=false +red IsPackable=false +sed IsPackable=false +lineeditor packable as Icod.LineEditor.Tools +``` + +Therefore solution-wide `dotnet pack` must produce exactly the router package for the coordinated release version: + +```text +Icod.LineEditor.Tools.1.1.0.nupkg +``` + +The standalone `ed`, `red`, and `sed` commands remain release artifacts through the six RID ZIP archives; disabling NuGet packing does not remove them from builds, tests, or executable archive publication. `Icod.LineEditor.Ed.Shared` remains a repository-local implementation library. + ## Version contract Repository versioning is centralized in the root `Directory.Build.props`: @@ -38,8 +60,8 @@ Repository versioning is centralized in the root `Directory.Build.props`: `VersionPrefix` is the single authoritative release-version literal. The repository derives: ```text -Version = 1.1.0 -PackageVersion = 1.1.0 +Version = 1.1.0 +PackageVersion = 1.1.0 AssemblyVersion = 1.1.0.0 FileVersion = 1.1.0.0 ``` @@ -68,7 +90,7 @@ Individual stages may be requested as `clean`, `restore`, `build`, `test`, `pack ### `VerifyPackageArtifact.ps1` -Validates generated `.nupkg` files supplied by the caller. It verifies package metadata, declared package README presence, and .NET tool metadata shape where applicable. The script supports repositories in which a given configuration legitimately produces no packages. +Validates generated `.nupkg` files supplied by the caller. It verifies package metadata, declared package README presence, and .NET tool metadata shape where applicable. For this repository, normal solution packing is expected to produce only `Icod.LineEditor.Tools`. ### `VerifyDistribution.ps1` @@ -155,7 +177,7 @@ archives ─────────────────────┘ Only packages whose nuspec version matches the release tag are selected. NuGet.org and GitHub Packages consume the same selected package artifact and use `--skip-duplicate`, allowing safe retries after partial publication. -GitHub Release creation waits for all applicable package-publication and archive jobs, writes `SHA256SUMS.txt`, and attaches the selected NuGet packages plus all six executable archives. +GitHub Release creation waits for all applicable package-publication and archive jobs, writes `SHA256SUMS.txt`, and attaches the selected NuGet package plus all six executable archives. ## NuGet Trusted Publishing @@ -165,7 +187,7 @@ NuGet.org publication requires: - an Actions secret named `NUGET_USER`; and - a NuGet.org Trusted Publishing policy authorizing repository `uniblab/Icod.LineEditor`, workflow `release.yaml`, and environment `Release`. -The package scope must authorize the package actually being published. For the router distribution that package ID is: +The package scope must authorize the package actually being published: ```text Icod.LineEditor.Tools @@ -190,8 +212,9 @@ That `README.md` is sourced from the repository root and packed at the NuGet pac Before pushing release tag `v1.1.0`: 1. confirm `Directory.Build.props` still declares `VersionPrefix` `1.1.0`; -2. confirm `lineeditor --version` reports `1.1.0` from assembly informational version metadata; -3. confirm the root README installation example and package identity are current; -4. confirm PR Staging validation is green; -5. merge to `main` and require the six-runner Release validation to pass; and -6. only then push tag `v1.1.0`. +2. confirm solution-wide pack produces only `Icod.LineEditor.Tools.1.1.0.nupkg`; +3. confirm `lineeditor --version` reports `1.1.0` from assembly informational version metadata; +4. confirm the root README installation example and package identity are current; +5. confirm PR Staging validation is green; +6. merge to `main` and require the six-runner Release validation to pass; and +7. only then push tag `v1.1.0`.