From 75c8a10728ed86d68118e1f462532c99d49902ba Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:06:24 +1000 Subject: [PATCH 01/12] test: cover safe example cleanup --- clean-all-examples.ps1 | 62 ++++++++++++------------ clean-all-examples.sh | 70 ++++++++++++++------------ tasks/plan.md | 55 +++++++++++++++++++++ tasks/todo.md | 45 +++++++++++++++++ tests/run_cleanup_smoke.ps1 | 97 +++++++++++++++++++++++++++++++++++++ tests/run_cleanup_smoke.sh | 63 ++++++++++++++++++++++++ 6 files changed, 331 insertions(+), 61 deletions(-) create mode 100644 tasks/plan.md create mode 100644 tasks/todo.md create mode 100644 tests/run_cleanup_smoke.ps1 create mode 100644 tests/run_cleanup_smoke.sh diff --git a/clean-all-examples.ps1 b/clean-all-examples.ps1 index e31b817..7ac0e7b 100644 --- a/clean-all-examples.ps1 +++ b/clean-all-examples.ps1 @@ -1,16 +1,9 @@ # clean-all-examples.ps1 -# Remove all built example binaries and unit output +# Remove generated example build artifacts while preserving tracked files. -$exampleBin = "example-bin" -if (Test-Path $exampleBin) { - Write-Host "๐Ÿงน Removing example-bin/ directory..." -ForegroundColor Yellow - Remove-Item $exampleBin -Recurse -Force - Write-Host "โœ… example-bin/ cleaned." -ForegroundColor Green -} else { - Write-Host "โ„น๏ธ example-bin/ does not exist. Nothing to clean." -ForegroundColor Gray -} +$RootDir = Split-Path -Parent $PSCommandPath -$examples = @( +$Examples = @( 'ColorDemo', 'ErrorHandlingDemo', 'LongRunningOpDemo', @@ -19,28 +12,35 @@ $examples = @( 'SimpleDemo', 'SubCommandDemo' ) +$GeneratedExtensions = @( + '.o', '.ppu', '.compiled', '.or', '.a', '.rst', '.res', '.dbg', '.tds', '.lps' +) +$GeneratedFileNames = @('link.res') +foreach ($Example in $Examples) { + $GeneratedFileNames += $Example + $GeneratedFileNames += "$Example.exe" +} -foreach ($ex in $examples) { - $libPath = "examples/$ex/lib" - if (Test-Path $libPath) { - Write-Host "๐Ÿงน Removing old lib/ from examples/$ex..." -ForegroundColor Yellow - Remove-Item $libPath -Recurse -Force - } - $win64Path = "examples/$ex/x86_64-win64" - if (Test-Path $win64Path) { - Write-Host "๐Ÿงน Removing old x86_64-win64/ from examples/$ex..." -ForegroundColor Yellow - Remove-Item $win64Path -Recurse -Force - } - $linuxPath = "examples/$ex/x86_64-linux" - if (Test-Path $linuxPath) { - Write-Host "๐Ÿงน Removing old x86_64-linux/ from examples/$ex..." -ForegroundColor Yellow - Remove-Item $linuxPath -Recurse -Force - } - $backupPath = "examples/$ex/backup" - if (Test-Path $backupPath) { - Write-Host "๐Ÿงน Cleaning backup/ in examples/$ex..." -ForegroundColor Yellow - Get-ChildItem $backupPath -Include *.exe,*.dbg,*.o,*.ppu -Recurse | Remove-Item -Force +function Remove-GeneratedArtifacts([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { + return } + + Get-ChildItem -LiteralPath $Path -File -Recurse -Force | + Where-Object { + ($GeneratedExtensions -contains $_.Extension.ToLowerInvariant()) -or + ($_.Name -like '*.lps.bak') -or + ($GeneratedFileNames -contains $_.Name) + } | + ForEach-Object { + Write-Host "๐Ÿงน Removing generated artifact: $($_.FullName)" -ForegroundColor Yellow + Remove-Item -LiteralPath $_.FullName -Force + } +} + +Remove-GeneratedArtifacts (Join-Path $RootDir 'example-bin') +foreach ($Example in $Examples) { + Remove-GeneratedArtifacts (Join-Path $RootDir "examples\$Example") } -Write-Host "`nโœ… Cleanup complete." -ForegroundColor Green +Write-Host "โœ… Generated example build artifacts removed." -ForegroundColor Green diff --git a/clean-all-examples.sh b/clean-all-examples.sh index f194b5b..ed31b56 100644 --- a/clean-all-examples.sh +++ b/clean-all-examples.sh @@ -1,34 +1,44 @@ #!/bin/bash # clean-all-examples.sh -# Remove all built example binaries and unit output - -set -e - -if [ -d "example-bin" ]; then - echo "๐Ÿงน Removing example-bin/ directory..." - rm -rf example-bin - echo "โœ… example-bin/ cleaned." -else - echo "โ„น๏ธ example-bin/ does not exist. Nothing to clean." -fi - -for ex in ColorDemo ErrorHandlingDemo LongRunningOpDemo ProgressDemo RootCommandDemo SimpleDemo SubCommandDemo; do - if [ -d "examples/$ex/lib" ]; then - echo "๐Ÿงน Removing old lib/ from examples/$ex..." - rm -rf "examples/$ex/lib" - fi - if [ -d "examples/$ex/x86_64-win64" ]; then - echo "๐Ÿงน Removing old x86_64-win64/ from examples/$ex..." - rm -rf "examples/$ex/x86_64-win64" - fi - if [ -d "examples/$ex/x86_64-linux" ]; then - echo "๐Ÿงน Removing old x86_64-linux/ from examples/$ex..." - rm -rf "examples/$ex/x86_64-linux" - fi - if [ -d "examples/$ex/backup" ]; then - echo "๐Ÿงน Cleaning backup/ in examples/$ex..." - find "examples/$ex/backup" -type f \( -name '*.exe' -o -name '*.dbg' -o -name '*.o' -o -name '*.ppu' \) -delete - fi +# Remove generated example build artifacts while preserving tracked files. + +set -eu + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +EXAMPLES="ColorDemo ErrorHandlingDemo LongRunningOpDemo ProgressDemo RootCommandDemo SimpleDemo SubCommandDemo" + +remove_generated_artifacts() { + directory="$1" + + [ -d "$directory" ] || return + + find "$directory" -type f \( \ + -name '*.o' -o \ + -name '*.ppu' -o \ + -name '*.compiled' -o \ + -name '*.or' -o \ + -name '*.a' -o \ + -name '*.rst' -o \ + -name '*.res' -o \ + -name '*.dbg' -o \ + -name '*.tds' -o \ + -name '*.lps' -o \ + -name '*.lps.bak' -o \ + -name 'ColorDemo' -o -name 'ColorDemo.exe' -o \ + -name 'ErrorHandlingDemo' -o -name 'ErrorHandlingDemo.exe' -o \ + -name 'LongRunningOpDemo' -o -name 'LongRunningOpDemo.exe' -o \ + -name 'ProgressDemo' -o -name 'ProgressDemo.exe' -o \ + -name 'RootCommandDemo' -o -name 'RootCommandDemo.exe' -o \ + -name 'SimpleDemo' -o -name 'SimpleDemo.exe' -o \ + -name 'SubCommandDemo' -o -name 'SubCommandDemo.exe' \ + \) -print -delete +} + +remove_generated_artifacts "$ROOT_DIR/example-bin" + +for ex in $EXAMPLES; do + remove_generated_artifacts "$ROOT_DIR/examples/$ex" done -echo "\nโœ… Cleanup complete." +echo "โœ… Generated example build artifacts removed." diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 0000000..412d857 --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,55 @@ +# Implementation Plan: v1.3.3 Stabilization + +## Overview + +Deliver the ROADMAP.md v1.3.3 stabilization work only: safe example cleanup, +behavioural test coverage for help and numeric parsing, CI compilation of the +seven canonical examples, and documentation of the changed behaviour. + +## Architecture Decisions + +- Keep cleanup targets allowlisted to generated compiler artifacts; never remove + source-controlled directories or files. +- Capture help output through an internal `TCLIApplication` test seam, without + adding a method to the public `ICLIApplication` API. +- Extend the existing parser and validation path for separated signed numbers; + do not create a parallel parsing path. + +## Task List + +### Phase 1: Safe cleanup + +- [ ] Task 1: Restrict both cleanup scripts to generated artifacts and add + cross-platform smoke checks that prove tracked files survive. + +### Phase 2: Runtime behaviour + +- [ ] Task 2: Replace placeholder help tests with output assertions using an + internal capture seam. +- [ ] Task 3: Support separated negative integer and float option values, with + regression coverage for equals, separated, and unknown-option forms. + +### Phase 3: Release integration + +- [ ] Task 4: Compile all seven canonical examples in Linux and Windows CI, + document the behavioural changes, and run the release verification suite. + +### Checkpoint: Complete + +- [ ] Cleanup smoke checks pass on their supported platforms. +- [ ] Framework and generator tests pass. +- [ ] All seven examples compile on the local platform and in both CI jobs. +- [ ] No public API was added or changed. + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Cleanup removes user content | High | Allowlist only generated compiler extensions and dedicated generated directories; assert tracked paths after cleanup. | +| Output capture changes runtime output | High | Keep it internal, disabled by default, and test the normal help execution path. | +| Signed numbers weaken option detection | High | Accept a leading `-` only when the registered parameter is integer or float and the candidate validates as numeric. | + +## Scope Guard + +No new command API, parameter kinds, generator features, completion features, +or broad application refactor is included. diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..8fdedf2 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,45 @@ +# v1.3.3 Task Checklist + +## Task 1: Safe example cleanup + +**Acceptance criteria:** + +- [ ] Shell and PowerShell cleanup remove generated artifacts only. +- [ ] Smoke checks build examples, run cleanup, and confirm tracked files remain. + +**Verification:** cleanup smoke tests on Linux and Windows. + +**Dependencies:** None. + +## Task 2: Help-output coverage + +**Acceptance criteria:** + +- [ ] Tests assert real usage, descriptions, required options, defaults, and subcommands. +- [ ] Capture support is internal to `TCLIApplication`; `ICLIApplication` remains unchanged. + +**Verification:** focused framework test suite. + +**Dependencies:** None. + +## Task 3: Negative numeric parsing + +**Acceptance criteria:** + +- [ ] Integer and float options accept equals and separated negative values. +- [ ] Unknown options remain errors. + +**Verification:** focused framework test suite. + +**Dependencies:** None. + +## Task 4: Release integration and documentation + +**Acceptance criteria:** + +- [ ] Both CI jobs compile the seven canonical examples. +- [ ] Release behaviour is documented in user-facing documentation and changelog. + +**Verification:** CI-script inspection and local compilation where available. + +**Dependencies:** Tasks 1โ€“3. diff --git a/tests/run_cleanup_smoke.ps1 b/tests/run_cleanup_smoke.ps1 new file mode 100644 index 0000000..4cf9157 --- /dev/null +++ b/tests/run_cleanup_smoke.ps1 @@ -0,0 +1,97 @@ +$ErrorActionPreference = 'Stop' + +$SourceRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ( + "cli-fp-cleanup-smoke-" + [System.Guid]::NewGuid().ToString("N") +) +$WorkRoot = Join-Path $TmpDir "cli-fp" + +function Assert-LastExitCode([string]$Message) { + if ($LASTEXITCODE -ne 0) { + throw $Message + } +} + +try { + git clone --quiet --no-hardlinks $SourceRoot $WorkRoot + Assert-LastExitCode "Failed to create isolated cleanup-smoke repository" + + $Examples = @( + 'ColorDemo', + 'ErrorHandlingDemo', + 'LongRunningOpDemo', + 'ProgressDemo', + 'RootCommandDemo', + 'SimpleDemo', + 'SubCommandDemo' + ) + $Sentinels = @( + 'example-bin/.gitkeep', + 'example-bin/README.md', + 'example-bin/simpledemo_completion.bash', + 'example-bin/simpledemo_completion.ps1', + 'example-bin/subcommanddemo_completion.bash', + 'example-bin/subcommanddemo_completion.ps1' + ) + + foreach ($Sentinel in $Sentinels) { + if (-not (Test-Path -LiteralPath (Join-Path $WorkRoot $Sentinel))) { + throw "Tracked cleanup sentinel is missing before the test: $Sentinel" + } + } + + $UnitRoot = Join-Path $TmpDir "units" + New-Item -ItemType Directory -Force -Path $UnitRoot | Out-Null + $BuildDir = Join-Path $WorkRoot "example-bin" + + foreach ($Example in $Examples) { + $UnitDir = Join-Path $UnitRoot $Example + New-Item -ItemType Directory -Force -Path $UnitDir | Out-Null + fpc ` + "-Fu$WorkRoot\src" ` + "-FE$BuildDir" ` + "-FU$UnitDir" ` + (Join-Path $WorkRoot "examples\$Example\$Example.lpr") + Assert-LastExitCode "Failed to compile example: $Example" + + $Binary = Join-Path $BuildDir "$Example.exe" + $UnixStyleBinary = Join-Path $BuildDir $Example + if (-not (Test-Path -LiteralPath $Binary) -and + -not (Test-Path -LiteralPath $UnixStyleBinary)) { + throw "Expected compiled example binary was not found: $Example" + } + } + + Push-Location $WorkRoot + try { + & (Join-Path $WorkRoot "clean-all-examples.ps1") | Out-Null + } + finally { + Pop-Location + } + + foreach ($Example in $Examples) { + if ((Test-Path -LiteralPath (Join-Path $BuildDir "$Example.exe")) -or + (Test-Path -LiteralPath (Join-Path $BuildDir $Example))) { + throw "Generated example binary was not removed: $Example" + } + } + + foreach ($Sentinel in $Sentinels) { + $SentinelPath = Join-Path $WorkRoot $Sentinel + if (-not (Test-Path -LiteralPath $SentinelPath)) { + throw "Tracked cleanup sentinel was deleted: $Sentinel" + } + git -C $WorkRoot diff --quiet -- $Sentinel + Assert-LastExitCode "Tracked cleanup sentinel was changed: $Sentinel" + } + + git -C $WorkRoot diff --quiet + Assert-LastExitCode "Cleanup changed tracked repository files" + Write-Host "Example cleanup smoke check passed." +} +finally { + if (Test-Path -LiteralPath $TmpDir) { + Remove-Item -LiteralPath $TmpDir -Recurse -Force + } +} diff --git a/tests/run_cleanup_smoke.sh b/tests/run_cleanup_smoke.sh new file mode 100644 index 0000000..03c863a --- /dev/null +++ b/tests/run_cleanup_smoke.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +WORK_ROOT="$TMP_DIR/cli-fp" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +git clone --quiet --no-hardlinks "$SOURCE_ROOT" "$WORK_ROOT" + +EXAMPLES=( + ColorDemo + ErrorHandlingDemo + LongRunningOpDemo + ProgressDemo + RootCommandDemo + SimpleDemo + SubCommandDemo +) +SENTINELS=( + example-bin/.gitkeep + example-bin/README.md + example-bin/simpledemo_completion.bash + example-bin/simpledemo_completion.ps1 + example-bin/subcommanddemo_completion.bash + example-bin/subcommanddemo_completion.ps1 +) + +for sentinel in "${SENTINELS[@]}"; do + test -f "$WORK_ROOT/$sentinel" +done + +mkdir -p "$TMP_DIR/units" +for example in "${EXAMPLES[@]}"; do + fpc \ + -Fu"$WORK_ROOT/src" \ + -FE"$WORK_ROOT/example-bin" \ + -FU"$TMP_DIR/units/$example" \ + "$WORK_ROOT/examples/$example/$example.lpr" >/dev/null + + test -f "$WORK_ROOT/example-bin/$example" || + test -f "$WORK_ROOT/example-bin/$example.exe" +done + +(cd "$WORK_ROOT" && ./clean-all-examples.sh >/dev/null) + +for example in "${EXAMPLES[@]}"; do + test ! -e "$WORK_ROOT/example-bin/$example" + test ! -e "$WORK_ROOT/example-bin/$example.exe" +done + +for sentinel in "${SENTINELS[@]}"; do + test -f "$WORK_ROOT/$sentinel" + git -C "$WORK_ROOT" diff --quiet -- "$sentinel" +done + +git -C "$WORK_ROOT" diff --quiet + +echo "Example cleanup smoke check passed." From e8d96c3b2e1df277e1991bf8b1d0f69ac27f2e5b Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:11:44 +1000 Subject: [PATCH 02/12] test: assert generated help output --- src/cli.application.pas | 208 ++++++++++++++++++++++++---------------- tests/run_tests.ps1 | 1 + tests/run_tests.sh | 1 + tests/testcase.pas | 86 +++++++++++++++-- 4 files changed, 209 insertions(+), 87 deletions(-) diff --git a/src/cli.application.pas b/src/cli.application.pas index 0695782..12abc32 100644 --- a/src/cli.application.pas +++ b/src/cli.application.pas @@ -9,7 +9,8 @@ interface uses - Classes, SysUtils, Generics.Collections, Generics.Defaults, CLI.Interfaces; + Classes, SysUtils, Generics.Collections, Generics.Defaults, CLI.Interfaces, + CLI.Console; type { List type for storing registered commands } @@ -53,6 +54,11 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) FParamStartIndex: Integer; // Index where command parameters start FDebugMode: Boolean; // Debug output flag FArguments: TStringArray; // Current arguments, excluding the executable + FOutputCapture: TStrings; // Internal help-output capture for tests + + { Writes help output to the console, or to the active test capture. } + procedure WriteOutput(const Text: string); overload; + procedure WriteOutput(const Text: string; const Color: TConsoleColor); overload; // Completion registry (simple array-based storage for FPC compatibility) // NOTE: Temporarily disabled - appears to cause issues with FPC @@ -199,6 +205,12 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) { For testing execution without changing the process command line. } function TestExecute(const Args: TStringArray): Integer; + + {$IFDEF CLI_FP_TESTING} + { Captures output from the normal execution path for framework tests only. } + function TestExecuteAndCapture(const Args: TStringArray; + const Output: TStrings): Integer; + {$ENDIF} end; const @@ -222,7 +234,7 @@ function CreateCLIApplication(const Name, Version: string; implementation uses - StrUtils, CLI.Console; + StrUtils; { Constructor: Initializes a new CLI application instance @param AName The name of the application @@ -240,11 +252,29 @@ constructor TCLIApplication.Create(const AName, AVersion: string; FParsedParams.CaseSensitive := True; // Parameters are case-sensitive FParamStartIndex := 2; // Skip program name and command name FDebugMode := False; // Debug output disabled by default + FOutputCapture := nil; SetLength(FArguments, 0); // Completion registries are auto-initialized as empty dynamic arrays end; +procedure TCLIApplication.WriteOutput(const Text: string); +begin + if Assigned(FOutputCapture) then + FOutputCapture.Add(Text) + else + TConsole.WriteLn(Text); +end; + +procedure TCLIApplication.WriteOutput(const Text: string; + const Color: TConsoleColor); +begin + if Assigned(FOutputCapture) then + FOutputCapture.Add(Text) + else + TConsole.WriteLn(Text, Color); +end; + { Destructor: Cleans up application resources Note: Ensures proper cleanup of command list and parameter storage } destructor TCLIApplication.Destroy; @@ -761,74 +791,74 @@ procedure TCLIApplication.ShowHelp; RequiredText: string; begin // Program header - TConsole.WriteLn(FName + ' version ' + FVersion); - TConsole.WriteLn(''); + WriteOutput(FName + ' version ' + FVersion); + WriteOutput(''); // Basic usage - TConsole.WriteLn('Usage:', ccCyan); + WriteOutput('Usage:', ccCyan); if Assigned(FRootCommand) then begin - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); if FCommands.Count > 0 then - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); end else - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); - TConsole.WriteLn(''); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); + WriteOutput(''); if Assigned(FRootCommand) and (FRootCommand.Description <> '') then begin - TConsole.WriteLn(FRootCommand.Description); - TConsole.WriteLn(''); + WriteOutput(FRootCommand.Description); + WriteOutput(''); end; if Assigned(FRootCommand) and (Length(FRootCommand.Parameters) > 0) then begin - TConsole.WriteLn('Options:', ccCyan); + WriteOutput('Options:', ccCyan); for Param in FRootCommand.Parameters do begin if Param.Required then RequiredText := ' (required)' else RequiredText := ''; - TConsole.WriteLn(' ' + Param.ShortFlag + ', ' + + WriteOutput(' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); if Param.DefaultValue <> '' then - TConsole.WriteLn(' Default: ' + Param.DefaultValue); + WriteOutput(' Default: ' + Param.DefaultValue); end; - TConsole.WriteLn(''); + WriteOutput(''); end; // Available commands if FCommands.Count > 0 then begin - TConsole.WriteLn('Commands:', ccCyan); + WriteOutput('Commands:', ccCyan); for Cmd in FCommands do - TConsole.WriteLn(' ' + PadRight(Cmd.Name, 15) + Cmd.Description); - TConsole.WriteLn(''); + WriteOutput(' ' + PadRight(Cmd.Name, 15) + Cmd.Description); + WriteOutput(''); end; // Global options - TConsole.WriteLn('Global Options:', ccCyan); - TConsole.WriteLn(' -h, --help Show this help message'); - TConsole.WriteLn(' --help-complete Show complete reference for all commands'); - TConsole.WriteLn(' --completion-file Output Bash completion script (redirect to a file)'); - TConsole.WriteLn(' --completion-file-pwsh Output PowerShell completion script (redirect to a .ps1 file)'); - TConsole.WriteLn(' -v, --version Show version information'); - TConsole.WriteLn(''); + WriteOutput('Global Options:', ccCyan); + WriteOutput(' -h, --help Show this help message'); + WriteOutput(' --help-complete Show complete reference for all commands'); + WriteOutput(' --completion-file Output Bash completion script (redirect to a file)'); + WriteOutput(' --completion-file-pwsh Output PowerShell completion script (redirect to a .ps1 file)'); + WriteOutput(' -v, --version Show version information'); + WriteOutput(''); // Examples section if FCommands.Count > 0 then begin - TConsole.WriteLn('Examples:', ccCyan); - TConsole.WriteLn(' Get help for commands:'); - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' --help'); - TConsole.WriteLn(''); - TConsole.WriteLn(' Available command help:'); + WriteOutput('Examples:', ccCyan); + WriteOutput(' Get help for commands:'); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' --help'); + WriteOutput(''); + WriteOutput(' Available command help:'); for Cmd in FCommands do - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' ' + Cmd.Name + ' --help'); - TConsole.WriteLn(''); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' ' + Cmd.Name + ' --help'); + WriteOutput(''); end; end; @@ -869,24 +899,24 @@ procedure TCLIApplication.ShowCommandHelp(const Command: ICommand); CommandPath := Command.Name; // Show usage and description - TConsole.WriteLn('Usage: ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' [options]'); - TConsole.WriteLn(''); - TConsole.WriteLn(Command.Description); + WriteOutput('Usage: ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' [options]'); + WriteOutput(''); + WriteOutput(Command.Description); // List subcommands if any if Length(Command.SubCommands) > 0 then begin - TConsole.WriteLn(''); - TConsole.WriteLn('Commands:', ccCyan); + WriteOutput(''); + WriteOutput('Commands:', ccCyan); for SubCmd in Command.SubCommands do - TConsole.WriteLn(' ' + PadRight(SubCmd.Name, 15) + SubCmd.Description); + WriteOutput(' ' + PadRight(SubCmd.Name, 15) + SubCmd.Description); end; // Show parameters if any if Length(Command.Parameters) > 0 then begin - TConsole.WriteLn(''); - TConsole.WriteLn('Options:', ccCyan); + WriteOutput(''); + WriteOutput('Options:', ccCyan); for Param in Command.Parameters do begin if Param.Required then @@ -894,33 +924,33 @@ procedure TCLIApplication.ShowCommandHelp(const Command: ICommand); else RequiredText := ''; - TConsole.WriteLn(' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + + WriteOutput(' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); if Param.DefaultValue <> '' then - TConsole.WriteLn(' Default: ' + Param.DefaultValue); + WriteOutput(' Default: ' + Param.DefaultValue); end; end; // Show examples for commands with subcommands if Length(Command.SubCommands) > 0 then begin - TConsole.WriteLn(''); - TConsole.WriteLn('Examples:', ccCyan); - TConsole.WriteLn(' Get help for commands:'); - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' --help'); - TConsole.WriteLn(''); - TConsole.WriteLn(' Available command help:'); + WriteOutput(''); + WriteOutput('Examples:', ccCyan); + WriteOutput(' Get help for commands:'); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' --help'); + WriteOutput(''); + WriteOutput(' Available command help:'); for SubCmd in Command.SubCommands do - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' ' + SubCmd.Name + ' --help'); - TConsole.WriteLn(''); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' ' + SubCmd.Name + ' --help'); + WriteOutput(''); end; end; { ShowVersion: Displays application version } procedure TCLIApplication.ShowVersion; begin - TConsole.WriteLn(FName + ' version ' + FVersion); + WriteOutput(FName + ' version ' + FVersion); end; { ShowCompleteHelp: Displays complete help for all commands @@ -942,48 +972,48 @@ procedure TCLIApplication.ShowCompleteHelp(const Indent: string = ''; const Comm if Command = nil then begin // Show program header and global information - TConsole.WriteLn(FName + ' version ' + FVersion); - TConsole.WriteLn(''); - TConsole.WriteLn('DESCRIPTION', ccCyan); + WriteOutput(FName + ' version ' + FVersion); + WriteOutput(''); + WriteOutput('DESCRIPTION', ccCyan); if Assigned(FRootCommand) and (FRootCommand.Description <> '') then - TConsole.WriteLn(' ' + FRootCommand.Description) + WriteOutput(' ' + FRootCommand.Description) else - TConsole.WriteLn(' Complete reference for all commands and options'); - TConsole.WriteLn(''); + WriteOutput(' Complete reference for all commands and options'); + WriteOutput(''); if Assigned(FRootCommand) and (Length(FRootCommand.Parameters) > 0) then begin - TConsole.WriteLn('ROOT OPTIONS', ccCyan); + WriteOutput('ROOT OPTIONS', ccCyan); for Param in FRootCommand.Parameters do begin if Param.Required then RequiredText := ' (required)' else RequiredText := ''; - TConsole.WriteLn(' ' + Param.ShortFlag + ', ' + + WriteOutput(' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); if Param.DefaultValue <> '' then - TConsole.WriteLn(' Default: ' + Param.DefaultValue); + WriteOutput(' Default: ' + Param.DefaultValue); end; - TConsole.WriteLn(''); + WriteOutput(''); end; - TConsole.WriteLn('GLOBAL OPTIONS', ccCyan); - TConsole.WriteLn(' -h, --help Show command help'); - TConsole.WriteLn(' --help-complete Show this complete reference'); - TConsole.WriteLn(' --completion-file Output Bash completion script (use --completion-file > myapp-completion.sh)'); - TConsole.WriteLn(' --completion-file-pwsh Output PowerShell completion script (use --completion-file-pwsh > myapp-completion.ps1)'); - TConsole.WriteLn(' -v, --version Show version information'); + WriteOutput('GLOBAL OPTIONS', ccCyan); + WriteOutput(' -h, --help Show command help'); + WriteOutput(' --help-complete Show this complete reference'); + WriteOutput(' --completion-file Output Bash completion script (use --completion-file > myapp-completion.sh)'); + WriteOutput(' --completion-file-pwsh Output PowerShell completion script (use --completion-file-pwsh > myapp-completion.ps1)'); + WriteOutput(' -v, --version Show version information'); if FCommands.Count > 0 then begin - TConsole.WriteLn(''); - TConsole.WriteLn('COMMANDS', ccCyan); + WriteOutput(''); + WriteOutput('COMMANDS', ccCyan); // Show all commands recursively for i := 0 to FCommands.Count - 1 do begin if i > 0 then - TConsole.WriteLn(''); + WriteOutput(''); ShowCompleteHelp(Indent + ' ', FCommands[i]); end; end; @@ -991,13 +1021,13 @@ procedure TCLIApplication.ShowCompleteHelp(const Indent: string = ''; const Comm else begin // Show command details - TConsole.WriteLn(Indent + Command.Name + ' - ' + Command.Description); + WriteOutput(Indent + Command.Name + ' - ' + Command.Description); // Show command parameters if Length(Command.Parameters) > 0 then begin - TConsole.WriteLn(''); - TConsole.WriteLn(Indent + 'OPTIONS:', ccCyan); + WriteOutput(''); + WriteOutput(Indent + 'OPTIONS:', ccCyan); for Param in Command.Parameters do begin if Param.Required then @@ -1005,23 +1035,23 @@ procedure TCLIApplication.ShowCompleteHelp(const Indent: string = ''; const Comm else RequiredText := ''; - TConsole.WriteLn(Indent + ' ' + Param.ShortFlag + ', ' + + WriteOutput(Indent + ' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); if Param.DefaultValue <> '' then - TConsole.WriteLn(Indent + ' Default: ' + Param.DefaultValue); + WriteOutput(Indent + ' Default: ' + Param.DefaultValue); end; end; // Show subcommands recursively if Length(Command.SubCommands) > 0 then begin - TConsole.WriteLn(''); - TConsole.WriteLn(Indent + 'SUBCOMMANDS:', ccCyan); + WriteOutput(''); + WriteOutput(Indent + 'SUBCOMMANDS:', ccCyan); for Cmd in Command.SubCommands do begin ShowCompleteHelp(Indent + ' ', Cmd); - TConsole.WriteLn(''); // Add a blank line after each subcommand for clarity + WriteOutput(''); // Add a blank line after each subcommand for clarity end; end; end; @@ -1029,9 +1059,9 @@ procedure TCLIApplication.ShowCompleteHelp(const Indent: string = ''; const Comm // Show help usage hint at root level if (Command = nil) and (FCommands.Count > 0) then begin - TConsole.WriteLn(''); - TConsole.WriteLn('For more details on a specific command, use:'); - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + ' --help'); + WriteOutput(''); + WriteOutput('For more details on a specific command, use:'); + WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' --help'); end; end; @@ -1569,6 +1599,22 @@ function TCLIApplication.TestExecute(const Args: TStringArray): Integer; Result := ExecuteArguments; end; +{$IFDEF CLI_FP_TESTING} +function TCLIApplication.TestExecuteAndCapture(const Args: TStringArray; + const Output: TStrings): Integer; +begin + if not Assigned(Output) then + raise EArgumentNilException.Create('Output capture cannot be nil'); + + FOutputCapture := Output; + try + Result := TestExecute(Args); + finally + FOutputCapture := nil; + end; +end; +{$ENDIF} + { OutputBashCompletionScript: Outputs a Bash completion script for the application } procedure TCLIApplication.OutputBashCompletionScript; procedure OutputBashTree(const Cmd: ICommand; const Path: string); diff --git a/tests/run_tests.ps1 b/tests/run_tests.ps1 index 4e620f3..a70c0e9 100644 --- a/tests/run_tests.ps1 +++ b/tests/run_tests.ps1 @@ -16,6 +16,7 @@ try { New-Item -ItemType Directory -Force -Path $UnitDir | Out-Null fpc ` + -dCLI_FP_TESTING ` "-Fu$RootDir\src" ` "-Fu$RootDir\tests" ` "-FE$TmpDir" ` diff --git a/tests/run_tests.sh b/tests/run_tests.sh index fa73a65..9ce097a 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -8,6 +8,7 @@ trap 'rm -rf "$TMP_DIR"' EXIT mkdir -p "$TMP_DIR/units" fpc \ + -dCLI_FP_TESTING \ -Fu"$ROOT_DIR/src" \ -Fu"$ROOT_DIR/tests" \ -FE"$TMP_DIR" \ diff --git a/tests/testcase.pas b/tests/testcase.pas index 6664187..99dda95 100644 --- a/tests/testcase.pas +++ b/tests/testcase.pas @@ -637,11 +637,35 @@ procedure TCLIFrameworkTests.Test_4_5_MultipleParameters; procedure TCLIFrameworkTests.Test_5_1_BasicHelp; var App: TCLIApplication; + Root, Deploy: TTestCommand; + Output: TStringList; begin - App := TCLIApplication.Create('TestApp', '1.0.0'); + Root := TTestCommand.Create('', 'Run the default action'); + Root.AddStringParameter('-c', '--config', 'Configuration file', True); + Root.AddStringParameter('-f', '--format', 'Output format', False, 'json'); + Deploy := TTestCommand.Create('deploy', 'Deploy the current release'); + App := TCLIApplication.Create('TestApp', '1.0.0', Root); + Output := TStringList.Create; try - AssertTrue('Should generate basic help', True); + App.RegisterCommand(Deploy); + AssertEquals('General help should succeed', 0, + App.TestExecuteAndCapture(MakeArgs(['--help']), Output)); + AssertTrue('General help should include the application version', + Pos('TestApp version 1.0.0', Output.Text) > 0); + AssertTrue('General help should include usage', + Pos('Usage:', Output.Text) > 0); + AssertTrue('General help should include the root description', + Pos('Run the default action', Output.Text) > 0); + AssertTrue('General help should include the required option', + Pos('--config', Output.Text) > 0); + AssertTrue('General help should label required options', + Pos('(required)', Output.Text) > 0); + AssertTrue('General help should include option defaults', + Pos('Default: json', Output.Text) > 0); + AssertTrue('General help should list command descriptions', + Pos('Deploy the current release', Output.Text) > 0); finally + Output.Free; App.Free; end; end; @@ -650,13 +674,26 @@ procedure TCLIFrameworkTests.Test_5_2_CommandHelp; var App: TCLIApplication; Cmd: TTestCommand; + Output: TStringList; begin App := TCLIApplication.Create('TestApp', '1.0.0'); Cmd := TTestCommand.Create('test', 'Test command'); + Output := TStringList.Create; try + Cmd.AddIntegerParameter('-r', '--retries', 'Retry count', False, '3'); App.RegisterCommand(Cmd); - AssertTrue('Should generate command help', True); + AssertEquals('Command help should succeed', 0, + App.TestExecuteAndCapture(MakeArgs(['test', '--help']), Output)); + AssertTrue('Command help should include command usage', + Pos('test [options]', Output.Text) > 0); + AssertTrue('Command help should include the command description', + Pos('Test command', Output.Text) > 0); + AssertTrue('Command help should include option descriptions', + Pos('Retry count', Output.Text) > 0); + AssertTrue('Command help should include option defaults', + Pos('Default: 3', Output.Text) > 0); finally + Output.Free; App.Free; end; end; @@ -664,11 +701,27 @@ procedure TCLIFrameworkTests.Test_5_2_CommandHelp; procedure TCLIFrameworkTests.Test_5_3_CompleteHelp; var App: TCLIApplication; + Cmd: TTestCommand; + Output: TStringList; begin App := TCLIApplication.Create('TestApp', '1.0.0'); + Cmd := TTestCommand.Create('report', 'Generate a report'); + Output := TStringList.Create; try - AssertTrue('Should generate complete help', True); + Cmd.AddStringParameter('-o', '--output', 'Output file', True); + App.RegisterCommand(Cmd); + AssertEquals('Complete help should succeed', 0, + App.TestExecuteAndCapture(MakeArgs(['--help-complete']), Output)); + AssertTrue('Complete help should include its heading', + Pos('DESCRIPTION', Output.Text) > 0); + AssertTrue('Complete help should include global options', + Pos('GLOBAL OPTIONS', Output.Text) > 0); + AssertTrue('Complete help should include registered commands', + Pos('report - Generate a report', Output.Text) > 0); + AssertTrue('Complete help should include required options', + Pos('--output', Output.Text) > 0); finally + Output.Free; App.Free; end; end; @@ -677,13 +730,24 @@ procedure TCLIFrameworkTests.Test_5_4_HelpExamples; var App: TCLIApplication; Cmd: TTestCommand; + Output: TStringList; begin App := TCLIApplication.Create('TestApp', '1.0.0'); Cmd := TTestCommand.Create('test', 'Test command'); + Output := TStringList.Create; try + Cmd.AddStringParameter('-n', '--name', 'Name to greet', False, 'World'); App.RegisterCommand(Cmd); - AssertTrue('Should generate help examples', True); + AssertEquals('Command help should succeed', 0, + App.TestExecuteAndCapture(MakeArgs(['test', '--help']), Output)); + AssertTrue('Command help should include option flags', + Pos('--name', Output.Text) > 0); + AssertTrue('Command help should include option descriptions', + Pos('Name to greet', Output.Text) > 0); + AssertTrue('Command help should include the documented default', + Pos('Default: World', Output.Text) > 0); finally + Output.Free; App.Free; end; end; @@ -692,15 +756,25 @@ procedure TCLIFrameworkTests.Test_5_5_SubCommandHelp; var App: TCLIApplication; MainCmd, SubCmd: TTestCommand; + Output: TStringList; begin App := TCLIApplication.Create('TestApp', '1.0.0'); MainCmd := TTestCommand.Create('main', 'Main command'); SubCmd := TTestCommand.Create('sub', 'Sub command'); + Output := TStringList.Create; try MainCmd.AddSubCommand(SubCmd); App.RegisterCommand(MainCmd); - AssertTrue('Should generate subcommand help', True); + AssertEquals('Parent command help should succeed', 0, + App.TestExecuteAndCapture(MakeArgs(['main', '--help']), Output)); + AssertTrue('Parent help should include a subcommand section', + Pos('Commands:', Output.Text) > 0); + AssertTrue('Parent help should include subcommand names', + Pos('sub', Output.Text) > 0); + AssertTrue('Parent help should include subcommand descriptions', + Pos('Sub command', Output.Text) > 0); finally + Output.Free; App.Free; end; end; From ba5a7f7dfca26c3887353d3abc6dac321d561ba0 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:13:07 +1000 Subject: [PATCH 03/12] fix: parse separated negative numeric values --- src/cli.application.pas | 35 ++++++++++++++++++++++++++-- tests/testcase.pas | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/cli.application.pas b/src/cli.application.pas index 12abc32..bccd8dd 100644 --- a/src/cli.application.pas +++ b/src/cli.application.pas @@ -109,6 +109,10 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) @param Value Output parameter that receives the value @returns True if parameter has value, False otherwise } function GetParameterValue(const Param: ICommandParameter; out Value: string): Boolean; + + { Returns True when Candidate is a signed numeric value accepted by Param. } + function IsNegativeNumericValue(const Param: string; + const Candidate: string): Boolean; { Shows complete help for all commands @param Indent Current indentation level for formatting @@ -577,7 +581,9 @@ procedure TCLIApplication.ParseCommandLine; Value := Copy(Param, Pos('=', Param) + 1, Length(Param)); Param := Copy(Param, 1, Pos('=', Param) - 1); end - else if (i < ArgumentCount) and not StartsStr('-', ArgumentAt(i + 1)) then + else if (i < ArgumentCount) and + (not StartsStr('-', ArgumentAt(i + 1)) or + IsNegativeNumericValue(Param, ArgumentAt(i + 1))) then begin Value := ArgumentAt(i + 1); Inc(i); @@ -590,7 +596,9 @@ procedure TCLIApplication.ParseCommandLine; // Handle -p value format else if StartsStr('-', Param) then begin - if (i < ArgumentCount) and not StartsStr('-', ArgumentAt(i + 1)) then + if (i < ArgumentCount) and + (not StartsStr('-', ArgumentAt(i + 1)) or + IsNegativeNumericValue(Param, ArgumentAt(i + 1))) then begin Value := ArgumentAt(i + 1); Inc(i); @@ -616,6 +624,29 @@ procedure TCLIApplication.ParseCommandLine; end; end; +function TCLIApplication.IsNegativeNumericValue(const Param: string; + const Candidate: string): Boolean; +var + CommandParam: ICommandParameter; + IntValue: Integer; + FloatValue: Double; +begin + Result := False; + if not StartsStr('-', Candidate) then + Exit; + + CommandParam := ParamByFlag(FCurrentCommand, Param); + if not Assigned(CommandParam) then + Exit; + + case CommandParam.ParamType of + ptInteger: + Result := TryStrToInt(Candidate, IntValue); + ptFloat: + Result := TryStrToFloat(Candidate, FloatValue); + end; +end; + { FindCommand: Searches for a command by name @param Name The command name to find @returns ICommand if found, nil if not found diff --git a/tests/testcase.pas b/tests/testcase.pas index 99dda95..b910af3 100644 --- a/tests/testcase.pas +++ b/tests/testcase.pas @@ -47,6 +47,8 @@ TCLIFrameworkTests = class(TTestCase) procedure Test_4_3_EqualsSyntax; procedure Test_4_4_BooleanFlags; procedure Test_4_5_MultipleParameters; + procedure Test_4_6_NegativeNumericValues; + procedure Test_4_7_UnknownOptionStillFails; // 5.x - Help System Tests procedure Test_5_1_BasicHelp; @@ -632,6 +634,55 @@ procedure TCLIFrameworkTests.Test_4_5_MultipleParameters; end; end; +procedure TCLIFrameworkTests.Test_4_6_NegativeNumericValues; +var + Cmd: TRecordingCommand; + App: TCLIApplication; +begin + Cmd := TRecordingCommand.Create('measure', 'Measure a signed value'); + App := TCLIApplication.Create('TestApp', '1.3.3'); + try + Cmd.AddIntegerParameter('-c', '--count', 'Signed count', True); + Cmd.AddFloatParameter('-r', '--rate', 'Signed rate', True); + App.RegisterCommand(Cmd); + + AssertEquals('Separated negative integer and float values should succeed', 0, + App.TestExecute(MakeArgs(['measure', '--count', '-1', '--rate', '-2.5']))); + AssertEquals('Separated negative integer should be retained', '-1', + App.ParsedParams.Values['--count']); + AssertEquals('Separated negative float should be retained', '-2.5', + App.ParsedParams.Values['--rate']); + + AssertEquals('Equals-form negative integer and float values should succeed', 0, + App.TestExecute(MakeArgs(['measure', '--count=-3', '--rate=-4.75']))); + AssertEquals('Equals-form negative integer should be retained', '-3', + App.ParsedParams.Values['--count']); + AssertEquals('Equals-form negative float should be retained', '-4.75', + App.ParsedParams.Values['--rate']); + finally + App.Free; + end; +end; + +procedure TCLIFrameworkTests.Test_4_7_UnknownOptionStillFails; +var + Cmd: TRecordingCommand; + App: TCLIApplication; +begin + Cmd := TRecordingCommand.Create('measure', 'Measure a signed value'); + App := TCLIApplication.Create('TestApp', '1.3.3'); + try + Cmd.AddIntegerParameter('-c', '--count', 'Signed count', True); + App.RegisterCommand(Cmd); + AssertEquals('An unknown option must remain an error', 1, + App.TestExecute(MakeArgs(['measure', '--count', '-1', '--unknown']))); + AssertEquals('Unknown options must prevent command execution', 0, + Cmd.ExecuteCount); + finally + App.Free; + end; +end; + // 5.x - Help System Tests procedure TCLIFrameworkTests.Test_5_1_BasicHelp; From 357f82db001d3e286cdf01ada9035b32ee1dd500 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:15:20 +1000 Subject: [PATCH 04/12] ci: verify example builds and cleanup --- .github/workflows/tests.yml | 11 ++++++++ CHANGELOG.md | 19 +++++++++++++ README.md | 14 ++++++++-- docs/RELEASE_NOTES_v1.3.3.md | 53 ++++++++++++++++++++++++++++++++++++ docs/user-manual.md | 5 ++++ packages/lazarus/cli_fp.lpk | 2 +- tests/run_cleanup_smoke.sh | 2 +- 7 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 docs/RELEASE_NOTES_v1.3.3.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c0f29f1..e4c4f10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,12 +7,18 @@ on: - 'tools/cli-fp-gen/**' - 'tests/**' - 'src/**' + - 'examples/**' + - 'clean-all-examples.sh' + - 'clean-all-examples.ps1' - '.github/workflows/tests.yml' pull_request: paths: - 'tools/cli-fp-gen/**' - 'tests/**' - 'src/**' + - 'examples/**' + - 'clean-all-examples.sh' + - 'clean-all-examples.ps1' - '.github/workflows/tests.yml' permissions: @@ -31,6 +37,8 @@ jobs: sudo apt-get install -y fp-compiler fp-units-fcl python3 - name: Framework Unit Tests run: bash tests/run_tests.sh + - name: Example Build and Cleanup Smoke Test + run: bash tests/run_cleanup_smoke.sh - name: Generator Unit Tests run: bash tests/codegen/run_unit_tests.sh - name: Golden Output Test @@ -64,6 +72,9 @@ jobs: - name: Framework Unit Tests shell: powershell run: powershell -ExecutionPolicy Bypass -File tests\run_tests.ps1 + - name: Example Build and Cleanup Smoke Test + shell: powershell + run: powershell -ExecutionPolicy Bypass -File tests\run_cleanup_smoke.ps1 - name: Codegen Test Suite shell: powershell run: powershell -ExecutionPolicy Bypass -File tests\codegen\run_all_tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b3ffb..b097a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.3] - 2026-08-13 + +### Fixed + +- Cleanup scripts now remove only generated example build artifacts and retain + tracked completion scripts, documentation, and other repository files. +- Registered integer and float options now accept separated negative values, + such as `--count -1` and `--rate -2.5`, in addition to equals syntax. + +### Testing + +- Replaced placeholder help tests with assertions against generated general, + command, complete, and subcommand help output, including usage, + descriptions, required options, and defaults. +- Added Linux and Windows cleanup smoke checks that compile all seven canonical + examples, run cleanup, and verify tracked files remain intact. +- CI now runs the seven-example build and cleanup smoke check on both Windows + and Linux. + ## [1.3.2] - 2026-07-30 ### Fixed diff --git a/README.md b/README.md index f434261..70d14f5 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,10 @@ if GetParameterValue('--count', RawCount) and ``` `Password` values are stored as strings and are not automatically redacted. -`Path` values are not checked for existence. If a value starts with `-`, use -the equals form, such as `--count=-1`, so it is not interpreted as another -option. +`Path` values are not checked for existence. Registered integer and float +options accept negative values in both equals and separated forms, for example +`--count=-1` and `--count -1`. For other value types that begin with `-`, use +the equals form so the value is not interpreted as another option. See the [user manual](docs/user-manual.md#parameter-types-and-validation) for the complete registration and validation rules. @@ -259,6 +260,7 @@ Run the framework tests on Linux or macOS: ```bash bash tests/run_tests.sh +bash tests/run_cleanup_smoke.sh ``` Run the generator suites: @@ -275,8 +277,14 @@ On Windows: ```powershell powershell -ExecutionPolicy Bypass -File tests\run_tests.ps1 powershell -ExecutionPolicy Bypass -File tests\codegen\run_all_tests.ps1 +powershell -ExecutionPolicy Bypass -File tests\run_cleanup_smoke.ps1 ``` +The cleanup smoke check compiles all seven canonical examples in an isolated +copy, runs the cleanup script, and verifies that generated artifacts are +removed without changing tracked files. CI runs the equivalent Bash and +PowerShell checks on Linux and Windows. + CI runs the framework and generator suites on Windows and Linux. See [CONTRIBUTING.md](CONTRIBUTING.md) for coding style and pull-request guidance. diff --git a/docs/RELEASE_NOTES_v1.3.3.md b/docs/RELEASE_NOTES_v1.3.3.md new file mode 100644 index 0000000..dc5ed25 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.3.3.md @@ -0,0 +1,53 @@ +# Release Notes - cli-fp v1.3.3 + +**Release Date:** 2026-08-13 + +## Overview + +Version `1.3.3` is a stabilization release. It improves cleanup safety, +behavioural test coverage, and parser correctness without adding a public API +or changing existing command contracts. + +## Safe example cleanup + +`clean-all-examples.sh` and `clean-all-examples.ps1` now remove only generated +compiler output. They preserve the tracked completion scripts and documentation +in `example-bin/`, along with other repository files. + +New cross-platform smoke checks compile all seven canonical examples in an +isolated repository copy, run the cleanup script, and verify that generated +artifacts are removed while tracked files remain unchanged. GitHub Actions runs +these checks on Linux and Windows. + +## Trustworthy help coverage + +The framework tests now capture output from the normal help execution path +through an internal test-only seam. They assert application and command usage, +descriptions, required options, defaults, complete help, and subcommands. + +The capture seam is not included in normal builds and does not add a method to +the public `ICLIApplication` API. + +## Negative numeric options + +Registered integer and float options now accept separated signed values: + +```text +myapp measure --count -1 --rate -2.5 +``` + +The existing equals forms continue to work: + +```text +myapp measure --count=-1 --rate=-2.5 +``` + +Only candidates that parse as an integer or float for the registered option are +accepted this way; unknown options remain validation errors. + +## Compatibility + +No migration is required. This release adds no public command API, parameter +kinds, generator capabilities, completion features, or breaking changes. + +**Full Changelog:** [v1.3.2...v1.3.3](https://github.com/ikelaiah/cli-fp/compare/v1.3.2...v1.3.3) diff --git a/docs/user-manual.md b/docs/user-manual.md index 31403c7..f67d52c 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -776,6 +776,11 @@ The framework supports various parameter formats: - Short format: `-p value` - Boolean flags: `--flag` or `-f` (false by default, true when present) +Registered integer and float options accept negative values in either long +form: `--count=-1` and `--count -1` are equivalent. For values of other types +that begin with `-`, use the equals form so the parser does not treat the value +as another option. + Example: ```bash myapp test --flag # --flag is true diff --git a/packages/lazarus/cli_fp.lpk b/packages/lazarus/cli_fp.lpk index 8463bb9..4e02bbe 100644 --- a/packages/lazarus/cli_fp.lpk +++ b/packages/lazarus/cli_fp.lpk @@ -36,7 +36,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "/> - + diff --git a/tests/run_cleanup_smoke.sh b/tests/run_cleanup_smoke.sh index 03c863a..6217a0e 100644 --- a/tests/run_cleanup_smoke.sh +++ b/tests/run_cleanup_smoke.sh @@ -46,7 +46,7 @@ for example in "${EXAMPLES[@]}"; do test -f "$WORK_ROOT/example-bin/$example.exe" done -(cd "$WORK_ROOT" && ./clean-all-examples.sh >/dev/null) +(cd "$WORK_ROOT" && bash ./clean-all-examples.sh >/dev/null) for example in "${EXAMPLES[@]}"; do test ! -e "$WORK_ROOT/example-bin/$example" From 7c7142f1c5410942291efea7ca5f254d05c5e1e8 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:15:59 +1000 Subject: [PATCH 05/12] test: isolate cleanup smoke repository --- tests/run_cleanup_smoke.ps1 | 2 +- tests/run_cleanup_smoke.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/run_cleanup_smoke.ps1 b/tests/run_cleanup_smoke.ps1 index 4cf9157..adc2296 100644 --- a/tests/run_cleanup_smoke.ps1 +++ b/tests/run_cleanup_smoke.ps1 @@ -13,7 +13,7 @@ function Assert-LastExitCode([string]$Message) { } try { - git clone --quiet --no-hardlinks $SourceRoot $WorkRoot + git -c "safe.directory=$SourceRoot" clone --quiet --no-hardlinks $SourceRoot $WorkRoot Assert-LastExitCode "Failed to create isolated cleanup-smoke repository" $Examples = @( diff --git a/tests/run_cleanup_smoke.sh b/tests/run_cleanup_smoke.sh index 6217a0e..c2f8adb 100644 --- a/tests/run_cleanup_smoke.sh +++ b/tests/run_cleanup_smoke.sh @@ -10,7 +10,8 @@ cleanup() { } trap cleanup EXIT -git clone --quiet --no-hardlinks "$SOURCE_ROOT" "$WORK_ROOT" +git -c safe.directory="$SOURCE_ROOT" clone --quiet --no-hardlinks \ + "$SOURCE_ROOT" "$WORK_ROOT" EXAMPLES=( ColorDemo From 0e41f7c2afe4fb5945decca57da853e5885b7e76 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:16:34 +1000 Subject: [PATCH 06/12] fix: trust cleanup smoke source repository --- tests/run_cleanup_smoke.ps1 | 2 +- tests/run_cleanup_smoke.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/run_cleanup_smoke.ps1 b/tests/run_cleanup_smoke.ps1 index adc2296..7a3a44f 100644 --- a/tests/run_cleanup_smoke.ps1 +++ b/tests/run_cleanup_smoke.ps1 @@ -13,7 +13,7 @@ function Assert-LastExitCode([string]$Message) { } try { - git -c "safe.directory=$SourceRoot" clone --quiet --no-hardlinks $SourceRoot $WorkRoot + git -c "safe.directory=$SourceRoot\.git" clone --quiet --no-hardlinks $SourceRoot $WorkRoot Assert-LastExitCode "Failed to create isolated cleanup-smoke repository" $Examples = @( diff --git a/tests/run_cleanup_smoke.sh b/tests/run_cleanup_smoke.sh index c2f8adb..c069428 100644 --- a/tests/run_cleanup_smoke.sh +++ b/tests/run_cleanup_smoke.sh @@ -10,7 +10,7 @@ cleanup() { } trap cleanup EXIT -git -c safe.directory="$SOURCE_ROOT" clone --quiet --no-hardlinks \ +git -c safe.directory="$SOURCE_ROOT/.git" clone --quiet --no-hardlinks \ "$SOURCE_ROOT" "$WORK_ROOT" EXAMPLES=( From 177a05c8e052c9d1fe978ec1a143b5920e03fa83 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:20:17 +1000 Subject: [PATCH 07/12] docs: complete v1.3.3 task records --- tasks/plan.md | 16 ++++++++-------- tasks/todo.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tasks/plan.md b/tasks/plan.md index 412d857..f54fe4f 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -19,27 +19,27 @@ seven canonical examples, and documentation of the changed behaviour. ### Phase 1: Safe cleanup -- [ ] Task 1: Restrict both cleanup scripts to generated artifacts and add +- [x] Task 1: Restrict both cleanup scripts to generated artifacts and add cross-platform smoke checks that prove tracked files survive. ### Phase 2: Runtime behaviour -- [ ] Task 2: Replace placeholder help tests with output assertions using an +- [x] Task 2: Replace placeholder help tests with output assertions using an internal capture seam. -- [ ] Task 3: Support separated negative integer and float option values, with +- [x] Task 3: Support separated negative integer and float option values, with regression coverage for equals, separated, and unknown-option forms. ### Phase 3: Release integration -- [ ] Task 4: Compile all seven canonical examples in Linux and Windows CI, +- [x] Task 4: Compile all seven canonical examples in Linux and Windows CI, document the behavioural changes, and run the release verification suite. ### Checkpoint: Complete -- [ ] Cleanup smoke checks pass on their supported platforms. -- [ ] Framework and generator tests pass. -- [ ] All seven examples compile on the local platform and in both CI jobs. -- [ ] No public API was added or changed. +- [x] Windows cleanup smoke check passes; CI runs the platform-native checks. +- [x] Framework and generator tests pass on Windows. +- [x] All seven examples compile locally on Windows; both CI jobs run the check. +- [x] No public API was added or changed. ## Risks and Mitigations diff --git a/tasks/todo.md b/tasks/todo.md index 8fdedf2..9ac87cb 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -4,8 +4,8 @@ **Acceptance criteria:** -- [ ] Shell and PowerShell cleanup remove generated artifacts only. -- [ ] Smoke checks build examples, run cleanup, and confirm tracked files remain. +- [x] Shell and PowerShell cleanup remove generated artifacts only. +- [x] Smoke checks build examples, run cleanup, and confirm tracked files remain. **Verification:** cleanup smoke tests on Linux and Windows. @@ -15,8 +15,8 @@ **Acceptance criteria:** -- [ ] Tests assert real usage, descriptions, required options, defaults, and subcommands. -- [ ] Capture support is internal to `TCLIApplication`; `ICLIApplication` remains unchanged. +- [x] Tests assert real usage, descriptions, required options, defaults, and subcommands. +- [x] Capture support is internal to `TCLIApplication`; `ICLIApplication` remains unchanged. **Verification:** focused framework test suite. @@ -26,8 +26,8 @@ **Acceptance criteria:** -- [ ] Integer and float options accept equals and separated negative values. -- [ ] Unknown options remain errors. +- [x] Integer and float options accept equals and separated negative values. +- [x] Unknown options remain errors. **Verification:** focused framework test suite. @@ -37,8 +37,8 @@ **Acceptance criteria:** -- [ ] Both CI jobs compile the seven canonical examples. -- [ ] Release behaviour is documented in user-facing documentation and changelog. +- [x] Both CI jobs are configured to compile the seven canonical examples. +- [x] Release behaviour is documented in user-facing documentation and changelog. **Verification:** CI-script inspection and local compilation where available. From 2ee95d4d03261a5172d21dc72f3154fc9bb35c44 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:23:59 +1000 Subject: [PATCH 08/12] docs: add v1.3.3 pull request notes --- docs/PULL_REQUEST_v1.3.3.md | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/PULL_REQUEST_v1.3.3.md diff --git a/docs/PULL_REQUEST_v1.3.3.md b/docs/PULL_REQUEST_v1.3.3.md new file mode 100644 index 0000000..9eee055 --- /dev/null +++ b/docs/PULL_REQUEST_v1.3.3.md @@ -0,0 +1,94 @@ +# Pull Request: Release v1.3.3 - Stabilize Before Expanding + +**Target Release:** v1.3.3 + +**Release Date:** 2026-08-13 + +## Summary + +This PR delivers the focused `v1.3.3` roadmap milestone. It makes example +cleanup safe, replaces placeholder help tests with behavioural assertions, +accepts separated negative integer and float values, and keeps all seven +canonical examples buildable in Windows and Linux CI. + +The release adds no public command API, parameter kind, generator capability, +or completion feature. + +## Type of Change + +- [x] Bug fix +- [x] Regression test +- [x] CI coverage +- [x] Documentation +- [ ] Breaking change + +## Safe Example Cleanup + +- [x] Restrict Bash and PowerShell cleanup scripts to generated compiler + artifacts. +- [x] Preserve tracked completion scripts, documentation, and other + repository files. +- [x] Add isolated Bash and PowerShell cleanup smoke checks. +- [x] Compile all seven canonical examples before each cleanup check. +- [x] Verify generated binaries are removed and tracked files remain unchanged. + +## Help Coverage + +- [x] Add an internal output-capture seam for framework tests. +- [x] Keep output capture out of normal builds and retain the public + `ICLIApplication` API unchanged. +- [x] Replace placeholder tests with assertions for usage, descriptions, + required options, defaults, complete help, and subcommands. + +## Parser Correctness + +- [x] Accept `--count -1` for registered integer options. +- [x] Accept `--rate -2.5` for registered float options. +- [x] Retain existing equals syntax such as `--count=-1`. +- [x] Confirm unknown options still fail validation and prevent command + execution. + +## CI, Documentation, and Versioning + +- [x] Configure the example build-and-cleanup smoke check in Windows and Linux CI. +- [x] Run that CI check when runtime, example, cleanup-script, test, or + workflow files change. +- [x] Update the README and user manual with negative-value behaviour and the + cleanup smoke command. +- [x] Add the dated v1.3.3 changelog entry and release notes. +- [x] Update Lazarus package metadata to `1.3.3`. +- [x] Add this pull request note. + +## Compatibility + +No migration is required. Existing command registration, validation, +completion, generator behaviour, and schema-version-1 projects remain +compatible. + +The parser accepts a separated leading `-` only when it is a valid integer or +float value for the registered option. Values beginning with `-` for other +parameter kinds continue to require equals syntax. + +## Verification + +- [x] Framework suite: 41 tests, 0 errors, 0 failures. +- [x] Separated and equals-form negative integer and float regression coverage. +- [x] Unknown-option regression coverage. +- [x] Windows example build-and-cleanup smoke check covering all seven examples. +- [x] Windows generator unit, golden-output, compile-smoke, and operations + suites. +- [x] Lazarus runtime package build in an isolated clean clone with version + metadata at `1.3.3`. +- [x] `git diff --check` passes. +- [x] FPC 3.2.2. +- [ ] GitHub Actions on Windows and Linux after the PR is opened. + +## Release Readiness + +- [x] Release date finalized as 2026-08-13. +- [x] Version metadata updated to `1.3.3`. +- [x] Changelog and release notes prepared. +- [x] Pull request notes prepared. +- [ ] Confirm GitHub Actions on Windows and Linux. + +After merge, create the `v1.3.3` tag and publish the prepared release notes. From 7a09a6421b10a514b5ef48f2bebd1facc7a2065f Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 20:30:28 +1000 Subject: [PATCH 09/12] fix: create Linux example unit directories --- tests/run_cleanup_smoke.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/run_cleanup_smoke.sh b/tests/run_cleanup_smoke.sh index c069428..e6e1ca0 100644 --- a/tests/run_cleanup_smoke.sh +++ b/tests/run_cleanup_smoke.sh @@ -35,13 +35,13 @@ for sentinel in "${SENTINELS[@]}"; do test -f "$WORK_ROOT/$sentinel" done -mkdir -p "$TMP_DIR/units" for example in "${EXAMPLES[@]}"; do + mkdir -p "$TMP_DIR/units/$example" fpc \ -Fu"$WORK_ROOT/src" \ -FE"$WORK_ROOT/example-bin" \ -FU"$TMP_DIR/units/$example" \ - "$WORK_ROOT/examples/$example/$example.lpr" >/dev/null + "$WORK_ROOT/examples/$example/$example.lpr" test -f "$WORK_ROOT/example-bin/$example" || test -f "$WORK_ROOT/example-bin/$example.exe" From cc808cfdcf6ccd522694f6599431fd3d5f9a36db Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 21:44:39 +1000 Subject: [PATCH 10/12] refactor: harden v1.3.3 internals --- CHANGELOG.md | 14 + README.md | 3 +- ROADMAP.md | 46 +- docs/PULL_REQUEST_v1.3.3.md | 22 +- docs/RELEASE_NOTES_v1.3.3.md | 28 +- docs/api-reference.md | 6 +- docs/technical-docs.md | 65 +- docs/user-manual.md | 10 +- packages/lazarus/cli_fp.lpk | 15 + packages/lazarus/cli_fp.pas | 2 +- src/cli.application.pas | 962 ++++++--------------------- src/cli.command.pas | 128 +--- src/cli.interfaces.pas | 2 +- src/cli.internal.completion.pas | 307 +++++++++ src/cli.internal.help.pas | 311 +++++++++ src/cli.internal.parametervalues.pas | 102 +++ tasks/plan.md | 31 +- tasks/todo.md | 55 ++ tests/run_tests.ps1 | 1 + tests/run_tests.sh | 1 + tests/testcase.pas | 107 +++ 21 files changed, 1288 insertions(+), 930 deletions(-) create mode 100644 src/cli.internal.completion.pas create mode 100644 src/cli.internal.help.pas create mode 100644 src/cli.internal.parametervalues.pas diff --git a/CHANGELOG.md b/CHANGELOG.md index b097a6c..12e27c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tracked completion scripts, documentation, and other repository files. - Registered integer and float options now accept separated negative values, such as `--count -1` and `--rate -2.5`, in addition to equals syntax. +- Debug output now redacts values supplied to registered password parameters + in both separated and equals forms. + +### Changed + +- Split help rendering, completion calculation, and parameter-value handling + into focused internal units while preserving the existing public facade. +- Decomposed application dispatch into smaller command-selection, global- + request, help, and execution stages. +- Removed unreachable private completion callback paths and unused temporary + allocations. Deprecated public 1.x compatibility methods remain unchanged. ### Testing @@ -25,6 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 examples, run cleanup, and verify tracked files remain intact. - CI now runs the seven-example build and cleanup smoke check on both Windows and Linux. +- Framework test runners now force an isolated rebuild so stale compiler units + cannot bypass test-only defines or change whether the suite compiles. +- Added password-redaction and broader completion characterization coverage. ## [1.3.2] - 2026-07-30 diff --git a/README.md b/README.md index 70d14f5..59c17c8 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ if GetParameterValue('--count', RawCount) and WriteLn('Count: ', Count); ``` -`Password` values are stored as strings and are not automatically redacted. +`Password` values are stored as strings. Framework debug diagnostics redact +them, but output produced by your command or external logging does not. `Path` values are not checked for existence. Registered integer and float options accept negative values in both equals and separated forms, for example `--count=-1` and `--count -1`. For other value types that begin with `-`, use diff --git a/ROADMAP.md b/ROADMAP.md index a7730aa..84ba032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,6 +48,28 @@ safer to maintain before v1.4.0 adds another public entry point. - Preserve unknown-option detection and add regression coverage for both numeric forms. +### Hermetic tests and safe diagnostics + +- Make the Windows and Linux framework test runners rebuild the unit graph + with the test define into an isolated output directory. A previous normal + build must not leave a stale `.ppu` that changes whether the tests compile. +- Keep output-capture state and entry points out of normal runtime builds while + retaining one production execution and help-rendering path. +- Redact values for registered password parameters from debug output and add a + regression test proving credentials are never printed. + +### Internal maintenance boundaries + +- Move help formatting into one internal renderer shared by the application + and base-command paths. +- Move completion calculation into a focused internal engine and delete + unreachable private callback branches and unused temporary allocations, + while retaining the deprecated public 1.x no-op methods. +- Single-source parameter lookup and password redaction for validation, + execution, and diagnostics. +- Decompose application dispatch into focused stages without changing the + `TCLIApplication` facade or `ICLIApplication` contract. + ### Release acceptance criteria - Cleanup scripts leave all tracked files intact. @@ -55,18 +77,27 @@ safer to maintain before v1.4.0 adds another public entry point. Linux. - Help tests fail when required help content is removed or changed incorrectly. - Negative integer and float values work in equals and separated forms. +- Framework tests pass after a normal non-test build has produced reusable + units in the source tree or another configured unit-search directory. +- Normal runtime builds contain no test-output capture state or entry points. +- Debug output never prints values supplied to password parameters. +- Help rendering, completion calculation, and parameter-value semantics each + have one internal implementation covered by characterization tests. - Every behaviour changed in v1.3.3 is documented and has automated coverage. ### Non-goals - No new public command API or breaking API changes. - No new parameter kinds, generator capabilities, or completion features. -- No broad `TCLIApplication` split; that remains planned for v1.5.0. +- No replacement of the `TCLIApplication` facade or execution-state contract. +- No removal of public compatibility APIs or broad completion/help cleanup; + those changes remain planned for v1.5.0 and v2.0.0. - No large historical-documentation cleanup mixed into the behavioural fixes. **Maintenance outcome:** the repository can be cleaned safely, examples remain -buildable, and the test suite provides a dependable safety net for the v1.4.0 -ergonomics work. +buildable, test results do not depend on stale compiler units, diagnostics do +not expose password values, and the test suite provides a dependable safety +net for the v1.4.0 ergonomics work. ## v1.4.0 โ€” Make Simple CLIs Simple @@ -83,13 +114,14 @@ ergonomics work. **Maintenance outcome:** beginner-oriented ergonomics improve without creating a second framework to maintain. -## v1.5.0 โ€” Split the Application Core +## v1.5.0 โ€” Finish the Application Core Boundaries - Separate command selection and execution orchestration from parsing and validation. -- Extract help rendering from `TCLIApplication` behind the output seam proven - in v1.3.3. -- Separate completion calculation from Bash and PowerShell script rendering. +- Extract Bash and PowerShell script rendering from `TCLIApplication`, building + on the completion engine introduced in v1.3.3. +- Strengthen the internal help and completion boundaries introduced in v1.3.3 + without exposing them as new public APIs. - Preserve existing observable behaviour with the v1.3.3 characterization tests and focused tests around each extracted component. - Keep these internal changes behind the stable public facade. diff --git a/docs/PULL_REQUEST_v1.3.3.md b/docs/PULL_REQUEST_v1.3.3.md index 9eee055..5c94078 100644 --- a/docs/PULL_REQUEST_v1.3.3.md +++ b/docs/PULL_REQUEST_v1.3.3.md @@ -8,8 +8,9 @@ This PR delivers the focused `v1.3.3` roadmap milestone. It makes example cleanup safe, replaces placeholder help tests with behavioural assertions, -accepts separated negative integer and float values, and keeps all seven -canonical examples buildable in Windows and Linux CI. +accepts separated negative integer and float values, protects password debug +values, reduces internal application complexity, and keeps all seven canonical +examples buildable in Windows and Linux CI. The release adds no public command API, parameter kind, generator capability, or completion feature. @@ -48,6 +49,19 @@ or completion feature. - [x] Confirm unknown options still fail validation and prevent command execution. +## Internal Maintenance and Diagnostics + +- [x] Force isolated framework unit rebuilds on Windows and Linux. +- [x] Exclude capture-specific state and entry points from normal builds. +- [x] Redact separated and equals-form password values in framework debug + output. +- [x] Share one parameter-value implementation across validation and command + execution. +- [x] Consolidate application and base-command help formatting. +- [x] Extract completion calculation and remove unreachable private callback + paths and unused temporary allocations. +- [x] Decompose application dispatch while preserving the public facade. + ## CI, Documentation, and Versioning - [x] Configure the example build-and-cleanup smoke check in Windows and Linux CI. @@ -71,13 +85,13 @@ parameter kinds continue to require equals syntax. ## Verification -- [x] Framework suite: 41 tests, 0 errors, 0 failures. +- [x] Framework suite: 43 tests, 0 errors, 0 failures. - [x] Separated and equals-form negative integer and float regression coverage. - [x] Unknown-option regression coverage. - [x] Windows example build-and-cleanup smoke check covering all seven examples. - [x] Windows generator unit, golden-output, compile-smoke, and operations suites. -- [x] Lazarus runtime package build in an isolated clean clone with version +- [x] Lazarus runtime package build in an isolated clean source copy with version metadata at `1.3.3`. - [x] `git diff --check` passes. - [x] FPC 3.2.2. diff --git a/docs/RELEASE_NOTES_v1.3.3.md b/docs/RELEASE_NOTES_v1.3.3.md index dc5ed25..708718b 100644 --- a/docs/RELEASE_NOTES_v1.3.3.md +++ b/docs/RELEASE_NOTES_v1.3.3.md @@ -5,8 +5,9 @@ ## Overview Version `1.3.3` is a stabilization release. It improves cleanup safety, -behavioural test coverage, and parser correctness without adding a public API -or changing existing command contracts. +behavioural test coverage, parser correctness, diagnostic safety, and internal +maintenance boundaries without adding a public API or changing existing +command contracts. ## Safe example cleanup @@ -28,6 +29,29 @@ descriptions, required options, defaults, complete help, and subcommands. The capture seam is not included in normal builds and does not add a method to the public `ICLIApplication` API. +The framework test runners also force an isolated unit rebuild. Existing +non-test `.ppu` files can no longer bypass the test define or affect whether +the suite compiles. + +## Safer diagnostics + +`DebugMode` continues to show parsing details, but values associated with +registered password parameters are now written as `[REDACTED]`. This covers +both `--password value` and `--password=value` forms. Applications remain +responsible for redacting sensitive values in their own output and logging. + +## Smaller internal responsibilities + +Help formatting is now shared by the application and base-command paths. +Completion calculation and parameter-value handling live in focused internal +units, and application dispatch is divided into named stages. Unreachable +private completion callback branches and unused temporary allocations were +removed. + +The `TCLIApplication` facade, `ICLIApplication` contract, deprecated 1.x +completion compatibility methods, and all existing command APIs remain +unchanged. + ## Negative numeric options Registered integer and float options now accept separated signed values: diff --git a/docs/api-reference.md b/docs/api-reference.md index dcb27ba..918e3b8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -53,7 +53,7 @@ TParameterType = ( ptEnum, // Enumerated value (e.g., --log-level debug|info|warn|error) ptDateTime, // Date/time value (e.g., --start "2024-01-01 12:00") ptArray, // Comma-separated list (e.g., --tags tag1,tag2,tag3) - ptPassword, // Sensitive value stored as a string; no automatic redaction + ptPassword, // Sensitive string; framework debug output redacts its value ptUrl // URL value with format validation (e.g., --repo https://github.com/user/repo) ); ``` @@ -154,8 +154,8 @@ values and values with or without seconds may also be accepted. Treat strict. `AddPasswordParameter` records `ptPassword` metadata, but retrieved values are -ordinary strings. The framework does not automatically redact values written -by application code or external logging. +ordinary strings. Framework debug diagnostics redact these values; application +code and external logging remain responsible for their own redaction. #### Getting Parameter Values diff --git a/docs/technical-docs.md b/docs/technical-docs.md index a523bc6..6fada1d 100644 --- a/docs/technical-docs.md +++ b/docs/technical-docs.md @@ -218,9 +218,14 @@ end; The `TCLIApplication` class is the central component that: - Manages command registration - Holds an optional executable root command -- Handles command-line parsing -- Implements the help system -- Coordinates command execution +- Coordinates command-line parsing and execution through focused stages +- Delegates help formatting to `CLI.Internal.Help` +- Delegates completion calculation to `CLI.Internal.Completion` + +`CLI.Internal.ParameterValues` owns parameter lookup semantics shared by +validation and command execution. These units are internal implementation +boundaries; the public `TCLIApplication` facade and `ICLIApplication` contract +are unchanged. Key methods: ```pascal @@ -634,7 +639,8 @@ The completion system uses a **hidden `__complete` entrypoint** that shell scrip โ”‚ Executes: myapp __complete [tokens...] โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ CLI APPLICATION (src/cli.application.pas) โ”‚ +โ”‚ CLI APPLICATION + COMPLETION ENGINE โ”‚ +โ”‚ (src/cli.application.pas and src/cli.internal.completion.pas) โ”‚ โ”‚ โ”‚ โ”‚ TCLIApplication.Execute(): โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ @@ -646,7 +652,7 @@ The completion system uses a **hidden `__complete` entrypoint** that shell scrip โ”‚ โ–ผ โ”‚ โ”‚ HandleCompletion(): โ”‚ โ”‚ โ€ข Collect tokens from ParamStr(2..ParamCount) โ”‚ -โ”‚ โ€ข Call DoComplete(Tokens) โ”‚ +โ”‚ โ€ข Delegate through DoComplete(Tokens) to CompleteCLI() โ”‚ โ”‚ โ€ข Write suggestions to stdout (one per line) โ”‚ โ”‚ โ€ข Write directive as : on last line โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ @@ -654,7 +660,7 @@ The completion system uses a **hidden `__complete` entrypoint** that shell scrip โ”‚ Calls DoComplete() โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ DoComplete(Tokens): COMPLETION LOGIC ENGINE โ”‚ +โ”‚ CompleteCLI(Tokens): COMPLETION LOGIC ENGINE โ”‚ โ”‚ โ”‚ โ”‚ 1. ROOT-LEVEL FLAG CHECK โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ @@ -789,10 +795,12 @@ callback methods are deprecated and non-functional: **Implementation Approach:** -- **Built-in completion** (โœ… Working): `DoComplete()` traverses the registered - command tree and parameter definitions at runtime. Generated Bash and - PowerShell functions call the hidden `__complete` entrypoint; no callback - registry is needed for command, flag, boolean, or enum candidates. +- **Built-in completion** (โœ… Working): `CLI.Internal.Completion.CompleteCLI()` + traverses the registered command tree and parameter definitions at runtime. + The application retains a small `DoComplete()` compatibility wrapper. + Generated Bash and PowerShell functions call the hidden `__complete` + entrypoint; no callback registry is needed for command, flag, Boolean, or + enum candidates. - **Custom callbacks** (โš ๏ธ Deprecated): The concrete application class exposes `RegisterFlagValueCompletion()` and `RegisterPositionalCompletion()` only for @@ -873,39 +881,27 @@ end; The earlier experiment recorded `nil` or invalid callback retrieval under the project's FPC 3.2.2 build. No focused reproducer or compiler issue is linked, so this document does not attribute that result to a confirmed FPC limitation. -The current source simply leaves registration and lookup disabled. +The current source retains only the deprecated public registration no-ops; +unreachable private lookup and callback branches were removed in v1.3.3. #### What Works Instead Built-in completion avoids dynamic function pointer storage entirely: ```pascal -// Simplified shape of the private implementation +// Compatibility wrapper in CLI.Application function TCLIApplication.DoComplete(const Tokens: TStringArray): TStringList; begin - // ... command/flag matching logic ... - - // Boolean completion uses direct metadata-based logic - if Param.ParamType = ptBoolean then - begin - Suggestions.Add('true'); - Suggestions.Add('false'); - end; - - // Enum values are split from Param.AllowedValues, a pipe-separated string - if Param.ParamType = ptEnum then - begin - Vals.Delimiter := '|'; - Vals.DelimitedText := Param.AllowedValues; - for J := 0 to Vals.Count - 1 do - Suggestions.Add(Vals[J]); - end; + Result := CompleteCLI(Tokens, FRootCommand, CommandSnapshot); end; ``` +`CLI.Internal.Completion` performs the metadata-based Boolean and enum +completion and contains no callback lookup path. + **Why this works:** - No function pointers stored dynamically -- All logic is statically coded in `DoComplete()` +- Completion logic is statically coded in the internal completion engine - Parameter metadata (allowed values, types) stored as simple strings/enums - No retrieval of function pointers from dynamic arrays @@ -929,16 +925,13 @@ Only advanced scenarios requiring **runtime-dynamic** completions from external #### Code Location -The deprecated public stubs and their private lookup helpers can be found by -name in `src/cli.application.pas`: +The deprecated public stubs remain in `src/cli.application.pas`: - `RegisterFlagValueCompletion()` - `RegisterPositionalCompletion()` -- `GetRegisteredFlagCompletion()` -- `GetRegisteredPositionalCompletion()` -The public registration methods are deprecated no-ops. The private lookup -helpers retain TODO markers because no callback registry is active. +The built-in engine is in `src/cli.internal.completion.pas`. There are no +private callback lookup helpers or dormant callback branches. Before enabling the callback API, re-evaluate the design against the project's supported compiler and retain regression coverage for callback lifetime and diff --git a/docs/user-manual.md b/docs/user-manual.md index f67d52c..7c2e74d 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -614,8 +614,8 @@ AddPasswordParameter('-k', '--api-key', 'API Key'); ``` Password parameters are returned to command code as ordinary strings. The -framework does not automatically redact values written by your application or -external logging. +framework redacts them from its own `DebugMode` diagnostics, but it does not +redact values written by your application or external logging. ### Parameter Validation @@ -634,7 +634,8 @@ The framework validates all parameters before executing a command. Each paramete - **Enum**: Must match one of the pipe-separated allowed values - **URL**: Must start with http://, https://, git://, or ssh:// - **Array**: No validation on individual items -- **Password**: No validation or automatic output redaction +- **Password**: No validation; framework debug diagnostics are redacted, but + application and external logging output is not ### Error Messages @@ -802,7 +803,8 @@ myapp test # --flag is false ``` `DebugMode` is a `TCLIApplication` property and is not exposed by the `ICLIApplication` returned from the factory. Manage the concrete - instance's lifetime normally. + instance's lifetime normally. Values belonging to parameters registered + with `AddPasswordParameter` are shown as `[REDACTED]`. 2. **Parameter Errors** - Check parameter format: diff --git a/packages/lazarus/cli_fp.lpk b/packages/lazarus/cli_fp.lpk index 4e02bbe..517240b 100644 --- a/packages/lazarus/cli_fp.lpk +++ b/packages/lazarus/cli_fp.lpk @@ -42,6 +42,11 @@ SOFTWARE. "/> + + + + + @@ -54,6 +59,11 @@ SOFTWARE. "/> + + + + + @@ -62,6 +72,11 @@ SOFTWARE. "/> + + + + + diff --git a/packages/lazarus/cli_fp.pas b/packages/lazarus/cli_fp.pas index 7ae0faf..43ecc62 100644 --- a/packages/lazarus/cli_fp.pas +++ b/packages/lazarus/cli_fp.pas @@ -8,7 +8,7 @@ interface uses - CLI.Application, CLI.Command, CLI.Console, CLI.Errors, CLI.Interfaces, + CLI.Application, CLI.Command, CLI.Console, CLI.Errors, CLI.Interfaces, CLI.Parameter, CLI.Progress; implementation diff --git a/src/cli.application.pas b/src/cli.application.pas index bccd8dd..b9c78f3 100644 --- a/src/cli.application.pas +++ b/src/cli.application.pas @@ -54,16 +54,16 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) FParamStartIndex: Integer; // Index where command parameters start FDebugMode: Boolean; // Debug output flag FArguments: TStringArray; // Current arguments, excluding the executable - FOutputCapture: TStrings; // Internal help-output capture for tests + {$IFDEF CLI_FP_TESTING} + FOutputCapture: TStrings; // Exists only in framework test builds + {$ENDIF} { Writes help output to the console, or to the active test capture. } procedure WriteOutput(const Text: string); overload; procedure WriteOutput(const Text: string; const Color: TConsoleColor); overload; - - // Completion registry (simple array-based storage for FPC compatibility) - // NOTE: Temporarily disabled - appears to cause issues with FPC - // FFlagCompletions: TFlagCompletionList; - // FPosCompletions: TPosCompletionList; + procedure WriteHelpLine(const Text: string; const Color: TConsoleColor; + const UseColor: Boolean); + function CommandSnapshot: specialize TArray; { Parses command-line arguments into FParsedParams Handles both --param=value and -p value formats } @@ -81,6 +81,15 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) { Executes using the arguments already stored in FArguments. } function ExecuteArguments: Integer; + { Handles requests that do not select or execute a command. } + function HandleGlobalRequest: Boolean; + + { Selects the root or named command and resolves its subcommand path. } + function SelectCurrentCommand: Boolean; + + { Shows requested or implicit command help. } + function HandleCurrentCommandHelp: Boolean; + { Parses, validates, and executes FCurrentCommand. } function ExecuteCurrentCommand: Integer; @@ -113,11 +122,9 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) { Returns True when Candidate is a signed numeric value accepted by Param. } function IsNegativeNumericValue(const Param: string; const Candidate: string): Boolean; - - { Shows complete help for all commands - @param Indent Current indentation level for formatting - @param Command Current command being documented, nil for root level } - procedure ShowCompleteHelp(const Indent: string = ''; const Command: ICommand = nil); + + { Shows complete help for all commands. } + procedure ShowCompleteHelp; { Gets the list of registered commands @returns TCommandList containing all registered commands } @@ -145,15 +152,6 @@ TCLIApplication = class(TInterfacedObject, ICLIApplication) { Hidden completion entrypoint handler (invoked when first arg is '__complete') } procedure HandleCompletion; - { Helper method to get registered flag completion callback } - function GetRegisteredFlagCompletion(const CmdPath, Flag: string; out Func: TFlagValueCompletionFunc): Boolean; - - { Helper method to get registered positional completion callback } - function GetRegisteredPositionalCompletion(const CmdPath: string; ArgIndex: Integer; out Func: TPositionalCompletionFunc): Boolean; - - { Helper method to find parameter by flag } - function ParamByFlag(const Cmd: ICommand; const Flag: string): ICommandParameter; - { Internal completion implementation } function DoComplete(const Tokens: TStringArray): TStringList; public @@ -238,7 +236,8 @@ function CreateCLIApplication(const Name, Version: string; implementation uses - StrUtils; + StrUtils, CLI.Internal.ParameterValues, CLI.Internal.Help, + CLI.Internal.Completion; { Constructor: Initializes a new CLI application instance @param AName The name of the application @@ -256,7 +255,9 @@ constructor TCLIApplication.Create(const AName, AVersion: string; FParsedParams.CaseSensitive := True; // Parameters are case-sensitive FParamStartIndex := 2; // Skip program name and command name FDebugMode := False; // Debug output disabled by default + {$IFDEF CLI_FP_TESTING} FOutputCapture := nil; + {$ENDIF} SetLength(FArguments, 0); // Completion registries are auto-initialized as empty dynamic arrays @@ -264,19 +265,46 @@ constructor TCLIApplication.Create(const AName, AVersion: string; procedure TCLIApplication.WriteOutput(const Text: string); begin + {$IFDEF CLI_FP_TESTING} if Assigned(FOutputCapture) then - FOutputCapture.Add(Text) - else - TConsole.WriteLn(Text); + begin + FOutputCapture.Add(Text); + Exit; + end; + {$ENDIF} + TConsole.WriteLn(Text); end; procedure TCLIApplication.WriteOutput(const Text: string; const Color: TConsoleColor); begin + {$IFDEF CLI_FP_TESTING} if Assigned(FOutputCapture) then - FOutputCapture.Add(Text) + begin + FOutputCapture.Add(Text); + Exit; + end; + {$ENDIF} + TConsole.WriteLn(Text, Color); +end; + +procedure TCLIApplication.WriteHelpLine(const Text: string; + const Color: TConsoleColor; const UseColor: Boolean); +begin + if UseColor then + WriteOutput(Text, Color) else - TConsole.WriteLn(Text, Color); + WriteOutput(Text); +end; + +function TCLIApplication.CommandSnapshot: specialize TArray; +var + i: Integer; +begin + Result := nil; + SetLength(Result, FCommands.Count); + for i := 0 to FCommands.Count - 1 do + Result[i] := FCommands[i]; end; { Destructor: Cleans up application resources @@ -336,50 +364,17 @@ function TCLIApplication.Execute: Integer; Result := ExecuteArguments; end; -{ Execute: Main entry point for running the application - Handles: - - Parameter parsing - - Command identification - - Subcommand resolution - - Help display - - Command execution - @returns Integer exit code (0 for success, non-zero for error) } -function TCLIApplication.ExecuteArguments: Integer; -var - CmdName: string; - SubCmd: ICommand; - SubCmdName: string; - i: Integer; - CurrentCmd: ICommand; - Cmd: ICommand; +{ Handles completion, help, version, and shell-script requests. } +function TCLIApplication.HandleGlobalRequest: Boolean; begin - Result := 0; - FCurrentCommand := nil; - FParsedParams.Clear; - FParamStartIndex := 2; - - // With no arguments, an optional root command is the executable action. - if ArgumentCount = 0 then - begin - if not Assigned(FRootCommand) then - begin - ShowHelp; - Exit; - end; - - FCurrentCommand := FRootCommand; - FParamStartIndex := 1; - Exit(ExecuteCurrentCommand); - end; + Result := True; - // Hidden completion entrypoint (invoked by generated shell scripts) if ArgumentAt(1) = '__complete' then begin HandleCompletion; - Exit(0); + Exit; end; - // Show general help if -h or --help is the only argument if (ArgumentCount = 1) and ((ArgumentAt(1) = '-h') or (ArgumentAt(1) = '--help')) then begin @@ -387,14 +382,12 @@ function TCLIApplication.ExecuteArguments: Integer; Exit; end; - // Show complete help if --help-complete is the only argument if (ArgumentCount = 1) and (ArgumentAt(1) = '--help-complete') then begin ShowCompleteHelp; Exit; end; - // Show version if -v or --version is the only argument if (ArgumentCount = 1) and ((ArgumentAt(1) = '-v') or (ArgumentAt(1) = '--version')) then begin @@ -402,14 +395,11 @@ function TCLIApplication.ExecuteArguments: Integer; Exit; end; - // Handle global completion file flag if ArgumentAt(1) = '--completion-file' then begin - // Check if user is writing directly to .bashrc or .bash_profile (as argument) - if (ArgumentCount > 1) and ( - (Pos('.bashrc', LowerCase(ArgumentAt(2))) > 0) or - (Pos('.bash_profile', LowerCase(ArgumentAt(2))) > 0) - ) then + if (ArgumentCount > 1) and + ((Pos('.bashrc', LowerCase(ArgumentAt(2))) > 0) or + (Pos('.bash_profile', LowerCase(ArgumentAt(2))) > 0)) then begin TConsole.WriteLn('โš ๏ธ Warning: Do NOT write completion scripts directly to .bashrc or .bash_profile! Source them instead to avoid polluting your shell config.', ccYellow); TConsole.WriteLn('Example:'); @@ -420,7 +410,7 @@ function TCLIApplication.ExecuteArguments: Integer; OutputBashCompletionScript; Exit; end; - // Handle PowerShell completion file flag + if ArgumentAt(1) = '--completion-file-pwsh' then begin TConsole.WriteLn('# Usage: ./' + ExtractFileName(ParamStr(0)) + ' --completion-file-pwsh > myapp-completion.ps1'); @@ -430,79 +420,81 @@ function TCLIApplication.ExecuteArguments: Integer; OutputPowerShellCompletionScript; Exit; end; - - // Get and validate command name + + Result := False; +end; + +function TCLIApplication.SelectCurrentCommand: Boolean; +var + CmdName, SubCmdName: string; + CurrentCmd, SubCmd, Cmd: ICommand; + i: Integer; +begin + Result := False; CmdName := ArgumentAt(1); + if StartsStr('-', CmdName) then begin if not Assigned(FRootCommand) then begin TConsole.WriteLn('Error: No command specified', ccRed); ShowBriefHelp; - Exit(1); + Exit; end; - FCurrentCommand := FRootCommand; FParamStartIndex := 1; + Exit(True); end; - if not StartsStr('-', CmdName) then - begin - // Find main command. - CurrentCmd := FindCommand(CmdName); - if not Assigned(CurrentCmd) then - begin - TConsole.WriteLn('Error: Unknown command "' + CmdName + '"', ccRed); - ShowBriefHelp; - Exit(1); - end; - - FCurrentCommand := CurrentCmd; - - // Process subcommands if present. - i := 2; - while (i <= ArgumentCount) and not StartsStr('-', ArgumentAt(i)) do - begin - SubCmdName := ArgumentAt(i); - SubCmd := nil; + CurrentCmd := FindCommand(CmdName); + if not Assigned(CurrentCmd) then + begin + TConsole.WriteLn('Error: Unknown command "' + CmdName + '"', ccRed); + ShowBriefHelp; + Exit; + end; - // Search for matching subcommand. - for Cmd in CurrentCmd.SubCommands do + FCurrentCommand := CurrentCmd; + i := 2; + while (i <= ArgumentCount) and not StartsStr('-', ArgumentAt(i)) do + begin + SubCmdName := ArgumentAt(i); + SubCmd := nil; + for Cmd in CurrentCmd.SubCommands do + if SameText(Cmd.Name, SubCmdName) then begin - if SameText(Cmd.Name, SubCmdName) then - begin - SubCmd := Cmd; - Break; - end; + SubCmd := Cmd; + Break; end; - if Assigned(SubCmd) then - begin - CurrentCmd := SubCmd; - FCurrentCommand := SubCmd; - Inc(FParamStartIndex); - Inc(i); - end - else - begin - // Show available subcommands on error. - TConsole.WriteLn('Error: Unknown subcommand "' + SubCmdName + - '" for ' + CurrentCmd.Name, ccRed); - TConsole.WriteLn(''); - TConsole.WriteLn('Available subcommands:', ccCyan); - for Cmd in CurrentCmd.SubCommands do - TConsole.WriteLn(' ' + PadRight(Cmd.Name, 15) + Cmd.Description); - TConsole.WriteLn(''); - TConsole.WriteLn('Use "' + ExtractFileName(ParamStr(0)) + ' ' + - CurrentCmd.Name + ' --help" for more information.'); - Exit(1); - end; + if not Assigned(SubCmd) then + begin + TConsole.WriteLn('Error: Unknown subcommand "' + SubCmdName + + '" for ' + CurrentCmd.Name, ccRed); + TConsole.WriteLn(''); + TConsole.WriteLn('Available subcommands:', ccCyan); + for Cmd in CurrentCmd.SubCommands do + TConsole.WriteLn(' ' + PadRight(Cmd.Name, 15) + Cmd.Description); + TConsole.WriteLn(''); + TConsole.WriteLn('Use "' + ExtractFileName(ParamStr(0)) + ' ' + + CurrentCmd.Name + ' --help" for more information.'); + Exit; end; + + CurrentCmd := SubCmd; + FCurrentCommand := SubCmd; + Inc(FParamStartIndex); + Inc(i); end; + Result := True; +end; - // Check for help request for current command +function TCLIApplication.HandleCurrentCommandHelp: Boolean; +var + i: Integer; +begin + Result := True; for i := FParamStartIndex to ArgumentCount do - begin if (ArgumentAt(i) = '-h') or (ArgumentAt(i) = '--help') then begin if FCurrentCommand = FRootCommand then @@ -511,16 +503,41 @@ function TCLIApplication.ExecuteArguments: Integer; ShowCommandHelp(FCurrentCommand); Exit; end; - end; - // Show help for commands with subcommands when no subcommand specified if (FCurrentCommand <> FRootCommand) and (Length(FCurrentCommand.SubCommands) > 0) and (FParamStartIndex = 2) then begin ShowCommandHelp(FCurrentCommand); Exit; end; - + Result := False; +end; + +function TCLIApplication.ExecuteArguments: Integer; +begin + Result := 0; + FCurrentCommand := nil; + FParsedParams.Clear; + FParamStartIndex := 2; + + if ArgumentCount = 0 then + begin + if not Assigned(FRootCommand) then + begin + ShowHelp; + Exit; + end; + FCurrentCommand := FRootCommand; + FParamStartIndex := 1; + Exit(ExecuteCurrentCommand); + end; + + if HandleGlobalRequest then + Exit; + if not SelectCurrentCommand then + Exit(1); + if HandleCurrentCommandHelp then + Exit; Result := ExecuteCurrentCommand; end; @@ -564,13 +581,14 @@ procedure TCLIApplication.ParseCommandLine; i := FParamStartIndex; // Start after program name and command name(s) if FDebugMode then - TConsole.WriteLn('Parsing command line...', ccCyan); + WriteOutput('Parsing command line...', ccCyan); while i <= ArgumentCount do begin Param := ArgumentAt(i); if FDebugMode then - TConsole.WriteLn('Processing argument ' + IntToStr(i) + ': ' + Param, ccCyan); + WriteOutput('Processing argument ' + IntToStr(i) + ': ' + + RedactArgument(FCurrentCommand, Param), ccCyan); // Handle --param=value format if StartsStr('--', Param) then @@ -591,7 +609,8 @@ procedure TCLIApplication.ParseCommandLine; // Store flag with empty string if no value is provided FParsedParams.Values[Param] := Value; if FDebugMode then - TConsole.WriteLn(' Added: ' + Param + ' = ' + Value, ccCyan); + WriteOutput(' Added: ' + Param + ' = ' + + RedactParameterValue(FCurrentCommand, Param, Value), ccCyan); end // Handle -p value format else if StartsStr('-', Param) then @@ -608,7 +627,8 @@ procedure TCLIApplication.ParseCommandLine; // Store flag with empty string if no value is provided FParsedParams.Values[Param] := Value; if FDebugMode then - TConsole.WriteLn(' Added: ' + Param + ' = ' + Value, ccCyan); + WriteOutput(' Added: ' + Param + ' = ' + + RedactParameterValue(FCurrentCommand, Param, Value), ccCyan); end; Inc(i); @@ -616,10 +636,13 @@ procedure TCLIApplication.ParseCommandLine; if FDebugMode then begin - TConsole.WriteLn('Parsed parameters:', ccCyan); + WriteOutput('Parsed parameters:', ccCyan); for i := 0 to FParsedParams.Count - 1 do begin - TConsole.WriteLn(' ' + FParsedParams.Names[i] + ' = ' + FParsedParams.ValueFromIndex[i], ccCyan); + WriteOutput(' ' + FParsedParams.Names[i] + ' = ' + + RedactParameterValue(FCurrentCommand, FParsedParams.Names[i], + FParsedParams.ValueFromIndex[i]), + ccCyan); end; end; end; @@ -635,7 +658,7 @@ function TCLIApplication.IsNegativeNumericValue(const Param: string; if not StartsStr('-', Candidate) then Exit; - CommandParam := ParamByFlag(FCurrentCommand, Param); + CommandParam := FindParameterByFlag(FCurrentCommand, Param); if not Assigned(CommandParam) then Exit; @@ -746,66 +769,8 @@ function TCLIApplication.ValidateCommand: Boolean; Note: Checks both long and short forms of the parameter } function TCLIApplication.GetParameterValue(const Param: ICommandParameter; out Value: string): Boolean; -var - idx: Integer; - paramVal: string; begin - // Special handling for boolean flags (ptBoolean) - if Param.ParamType = ptBoolean then - begin - idx := FParsedParams.IndexOfName(Param.LongFlag); - if idx = -1 then - idx := FParsedParams.IndexOfName(Param.ShortFlag); - if idx <> -1 then - begin - paramVal := FParsedParams.ValueFromIndex[idx]; - if (paramVal = '') then - begin - Value := 'true'; // flag present, no value - Result := True; - Exit; - end - else if SameText(paramVal, 'true') or SameText(paramVal, 'false') then - begin - Value := paramVal; - Result := True; - Exit; - end - else - begin - Value := paramVal; - Result := True; - Exit; - end; - end - else if Param.DefaultValue <> '' then - begin - Value := Param.DefaultValue; - Result := True; - Exit; - end - else - begin - Value := 'false'; - Result := False; - Exit; - end; - end; - - Result := FParsedParams.Values[Param.LongFlag] <> ''; - if Result then - Value := FParsedParams.Values[Param.LongFlag] - else - begin - Result := FParsedParams.Values[Param.ShortFlag] <> ''; - if Result then - Value := FParsedParams.Values[Param.ShortFlag] - else if Param.DefaultValue <> '' then - begin - Value := Param.DefaultValue; - Result := True; - end; - end; + Result := TryGetParameterValue(Param, FParsedParams, Value); end; { ShowHelp: Displays general application help @@ -817,79 +782,15 @@ function TCLIApplication.GetParameterValue(const Param: ICommandParameter; - Usage examples } procedure TCLIApplication.ShowHelp; var - Cmd: ICommand; - Param: ICommandParameter; - RequiredText: string; + Renderer: TCLIHelpRenderer; begin - // Program header - WriteOutput(FName + ' version ' + FVersion); - WriteOutput(''); - - // Basic usage - WriteOutput('Usage:', ccCyan); - if Assigned(FRootCommand) then - begin - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); - if FCommands.Count > 0 then - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + - ' [options]'); - end - else - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' [options]'); - WriteOutput(''); - - if Assigned(FRootCommand) and (FRootCommand.Description <> '') then - begin - WriteOutput(FRootCommand.Description); - WriteOutput(''); - end; - - if Assigned(FRootCommand) and (Length(FRootCommand.Parameters) > 0) then - begin - WriteOutput('Options:', ccCyan); - for Param in FRootCommand.Parameters do - begin - if Param.Required then - RequiredText := ' (required)' - else - RequiredText := ''; - WriteOutput(' ' + Param.ShortFlag + ', ' + - PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); - if Param.DefaultValue <> '' then - WriteOutput(' Default: ' + Param.DefaultValue); - end; - WriteOutput(''); - end; - - // Available commands - if FCommands.Count > 0 then - begin - WriteOutput('Commands:', ccCyan); - for Cmd in FCommands do - WriteOutput(' ' + PadRight(Cmd.Name, 15) + Cmd.Description); - WriteOutput(''); - end; - - // Global options - WriteOutput('Global Options:', ccCyan); - WriteOutput(' -h, --help Show this help message'); - WriteOutput(' --help-complete Show complete reference for all commands'); - WriteOutput(' --completion-file Output Bash completion script (redirect to a file)'); - WriteOutput(' --completion-file-pwsh Output PowerShell completion script (redirect to a .ps1 file)'); - WriteOutput(' -v, --version Show version information'); - WriteOutput(''); - - // Examples section - if FCommands.Count > 0 then - begin - WriteOutput('Examples:', ccCyan); - WriteOutput(' Get help for commands:'); - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' --help'); - WriteOutput(''); - WriteOutput(' Available command help:'); - for Cmd in FCommands do - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' ' + Cmd.Name + ' --help'); - WriteOutput(''); + Renderer := TCLIHelpRenderer.Create(FName, FVersion, + ExtractFileName(ParamStr(0)), FRootCommand, CommandSnapshot, + @WriteHelpLine); + try + Renderer.ShowGeneral; + finally + Renderer.Free; end; end; @@ -904,11 +805,9 @@ procedure TCLIApplication.ShowHelp; - Usage examples } procedure TCLIApplication.ShowCommandHelp(const Command: ICommand); var - Param: ICommandParameter; - RequiredText: string; CommandPath: string; i: Integer; - SubCmd: ICommand; + Renderer: TCLIHelpRenderer; begin if Assigned(FRootCommand) and (Command = FRootCommand) then begin @@ -929,52 +828,13 @@ procedure TCLIApplication.ShowCommandHelp(const Command: ICommand); if CommandPath = '' then CommandPath := Command.Name; - // Show usage and description - WriteOutput('Usage: ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' [options]'); - WriteOutput(''); - WriteOutput(Command.Description); - - // List subcommands if any - if Length(Command.SubCommands) > 0 then - begin - WriteOutput(''); - WriteOutput('Commands:', ccCyan); - for SubCmd in Command.SubCommands do - WriteOutput(' ' + PadRight(SubCmd.Name, 15) + SubCmd.Description); - end; - - // Show parameters if any - if Length(Command.Parameters) > 0 then - begin - WriteOutput(''); - WriteOutput('Options:', ccCyan); - for Param in Command.Parameters do - begin - if Param.Required then - RequiredText := ' (required)' - else - RequiredText := ''; - - WriteOutput(' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + - Param.Description + RequiredText); - - if Param.DefaultValue <> '' then - WriteOutput(' Default: ' + Param.DefaultValue); - end; - end; - - // Show examples for commands with subcommands - if Length(Command.SubCommands) > 0 then - begin - WriteOutput(''); - WriteOutput('Examples:', ccCyan); - WriteOutput(' Get help for commands:'); - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' --help'); - WriteOutput(''); - WriteOutput(' Available command help:'); - for SubCmd in Command.SubCommands do - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' ' + CommandPath + ' ' + SubCmd.Name + ' --help'); - WriteOutput(''); + Renderer := TCLIHelpRenderer.Create(FName, FVersion, + ExtractFileName(ParamStr(0)), FRootCommand, CommandSnapshot, + @WriteHelpLine); + try + Renderer.ShowCommand(Command, CommandPath); + finally + Renderer.Free; end; end; @@ -985,114 +845,23 @@ procedure TCLIApplication.ShowVersion; end; { ShowCompleteHelp: Displays complete help for all commands - @param Indent Current indentation level for formatting - @param Command Current command being documented, nil for root level Shows: - Full application description - Global options - All commands with full details - All subcommands recursively - All parameters with defaults } -procedure TCLIApplication.ShowCompleteHelp(const Indent: string = ''; const Command: ICommand = nil); +procedure TCLIApplication.ShowCompleteHelp; var - Cmd: ICommand; - Param: ICommandParameter; - RequiredText: string; - i: Integer; + Renderer: TCLIHelpRenderer; begin - if Command = nil then - begin - // Show program header and global information - WriteOutput(FName + ' version ' + FVersion); - WriteOutput(''); - WriteOutput('DESCRIPTION', ccCyan); - if Assigned(FRootCommand) and (FRootCommand.Description <> '') then - WriteOutput(' ' + FRootCommand.Description) - else - WriteOutput(' Complete reference for all commands and options'); - WriteOutput(''); - - if Assigned(FRootCommand) and (Length(FRootCommand.Parameters) > 0) then - begin - WriteOutput('ROOT OPTIONS', ccCyan); - for Param in FRootCommand.Parameters do - begin - if Param.Required then - RequiredText := ' (required)' - else - RequiredText := ''; - WriteOutput(' ' + Param.ShortFlag + ', ' + - PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); - if Param.DefaultValue <> '' then - WriteOutput(' Default: ' + Param.DefaultValue); - end; - WriteOutput(''); - end; - - WriteOutput('GLOBAL OPTIONS', ccCyan); - WriteOutput(' -h, --help Show command help'); - WriteOutput(' --help-complete Show this complete reference'); - WriteOutput(' --completion-file Output Bash completion script (use --completion-file > myapp-completion.sh)'); - WriteOutput(' --completion-file-pwsh Output PowerShell completion script (use --completion-file-pwsh > myapp-completion.ps1)'); - WriteOutput(' -v, --version Show version information'); - if FCommands.Count > 0 then - begin - WriteOutput(''); - WriteOutput('COMMANDS', ccCyan); - - // Show all commands recursively - for i := 0 to FCommands.Count - 1 do - begin - if i > 0 then - WriteOutput(''); - ShowCompleteHelp(Indent + ' ', FCommands[i]); - end; - end; - end - else - begin - // Show command details - WriteOutput(Indent + Command.Name + ' - ' + Command.Description); - - // Show command parameters - if Length(Command.Parameters) > 0 then - begin - WriteOutput(''); - WriteOutput(Indent + 'OPTIONS:', ccCyan); - for Param in Command.Parameters do - begin - if Param.Required then - RequiredText := ' (required)' - else - RequiredText := ''; - - WriteOutput(Indent + ' ' + Param.ShortFlag + ', ' + - PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); - - if Param.DefaultValue <> '' then - WriteOutput(Indent + ' Default: ' + Param.DefaultValue); - end; - end; - - // Show subcommands recursively - if Length(Command.SubCommands) > 0 then - begin - WriteOutput(''); - WriteOutput(Indent + 'SUBCOMMANDS:', ccCyan); - for Cmd in Command.SubCommands do - begin - ShowCompleteHelp(Indent + ' ', Cmd); - WriteOutput(''); // Add a blank line after each subcommand for clarity - end; - end; - end; - - // Show help usage hint at root level - if (Command = nil) and (FCommands.Count > 0) then - begin - WriteOutput(''); - WriteOutput('For more details on a specific command, use:'); - WriteOutput(' ' + ExtractFileName(ParamStr(0)) + ' --help'); + Renderer := TCLIHelpRenderer.Create(FName, FVersion, + ExtractFileName(ParamStr(0)), FRootCommand, CommandSnapshot, + @WriteHelpLine); + try + Renderer.ShowComplete; + finally + Renderer.Free; end; end; @@ -1111,26 +880,16 @@ function TCLIApplication.GetCommands: TCommandList; - Help command reminder } procedure TCLIApplication.ShowBriefHelp; var - Cmd: ICommand; + Renderer: TCLIHelpRenderer; begin - // Show minimal help for error cases - if Assigned(FRootCommand) then - begin - TConsole.WriteLn('Usage: ' + ExtractFileName(ParamStr(0)) + - ' [options]'); - if FCommands.Count > 0 then - TConsole.WriteLn(' ' + ExtractFileName(ParamStr(0)) + - ' [options]'); - end - else - TConsole.WriteLn('Usage: ' + ExtractFileName(ParamStr(0)) + - ' [options]'); - TConsole.WriteLn(''); - TConsole.WriteLn('Commands:', ccCyan); - for Cmd in FCommands do - TConsole.WriteLn(' ' + PadRight(Cmd.Name, 15) + Cmd.Description); - TConsole.WriteLn(''); - TConsole.WriteLn('Use --help for more information.'); + Renderer := TCLIHelpRenderer.Create(FName, FVersion, + ExtractFileName(ParamStr(0)), FRootCommand, CommandSnapshot, + @WriteHelpLine); + try + Renderer.ShowBrief; + finally + Renderer.Free; + end; end; { CreateCLIApplication: Factory function to create new CLI application @@ -1257,341 +1016,9 @@ procedure TCLIApplication.RegisterPositionalCompletion(const CommandPath: string // Deprecated no-op retained for 1.x source compatibility. end; -{ GetRegisteredFlagCompletion: Helper to retrieve flag completion callback } -function TCLIApplication.GetRegisteredFlagCompletion(const CmdPath, Flag: string; out Func: TFlagValueCompletionFunc): Boolean; -begin - Result := False; - // TODO: Implement when FPC function pointer storage is resolved -end; - -{ GetRegisteredPositionalCompletion: Helper to retrieve positional completion callback } -function TCLIApplication.GetRegisteredPositionalCompletion(const CmdPath: string; ArgIndex: Integer; out Func: TPositionalCompletionFunc): Boolean; -begin - Result := False; - // TODO: Implement when FPC function pointer storage is resolved -end; - -{ ParamByFlag: Helper to find parameter by flag name } -function TCLIApplication.ParamByFlag(const Cmd: ICommand; const Flag: string): ICommandParameter; -var - P: ICommandParameter; -begin - Result := nil; - for P in Cmd.Parameters do - begin - if SameText(P.LongFlag, Flag) or SameText(P.ShortFlag, Flag) then - Exit(P); - end; -end; - -{ HandleCompletion: Implements the __complete hidden entrypoint - Accepts tokens as the rest of argv (ParamStr(2..ParamCount)) and writes - one completion candidate per line followed by : line. } function TCLIApplication.DoComplete(const Tokens: TStringArray): TStringList; -var - i, tc, idx, j: Integer; - Cmd: ICommand; - SubCmd: ICommand; - SCmd: ICommand; - pathParts: TStringList; - suggestions: TStringList; - directive: Integer; - toComplete: string; - isNewToken: Boolean; - Param: ICommandParameter; - Hook: TFlagValueCompletionFunc; - cmdPath: string; - argsArr: TStringArray; - argsArr2: TStringArray; - posList: TStringList; - res: TStringArray; - vals: TStringList; - posArgs: TStringList; - argIndex: Integer; - PosHook: TPositionalCompletionFunc; - pathBuilder: TStringList; begin - suggestions := TStringList.Create; - suggestions.Duplicates := dupIgnore; - suggestions.Sorted := False; - directive := 0; - - tc := Length(Tokens); - if tc = 0 then - begin - for i := 0 to FCommands.Count - 1 do - suggestions.Add(FCommands[i].Name); - // final directive line will be added by caller - Result := suggestions; - Exit; - end; - - // determine if the last token is an empty (new token) - isNewToken := False; - if (tc > 0) and (Tokens[tc - 1] = '') then - isNewToken := True; - - // A leading flag belongs to the optional root command. Without a root - // command, only global options are valid in this position. - if StartsStr('-', Tokens[0]) and not Assigned(FRootCommand) then - begin - toComplete := Tokens[0]; - // Complete global flags - if StartsStr(LowerCase(toComplete), '--help') then suggestions.Add('--help'); - if StartsStr(LowerCase(toComplete), '--help-complete') then suggestions.Add('--help-complete'); - if StartsStr(LowerCase(toComplete), '--version') then suggestions.Add('--version'); - if StartsStr(LowerCase(toComplete), '--completion-file') then suggestions.Add('--completion-file'); - if StartsStr(LowerCase(toComplete), '--completion-file-pwsh') then suggestions.Add('--completion-file-pwsh'); - if StartsStr(LowerCase(toComplete), '-h') then suggestions.Add('-h'); - if StartsStr(LowerCase(toComplete), '-v') then suggestions.Add('-v'); - suggestions.Add(':'+IntToStr(directive)); - Result := suggestions; - Exit; - end; - - // Resolve a named command path, or use the root command for leading flags. - if StartsStr('-', Tokens[0]) then - begin - Cmd := FRootCommand; - idx := 0; - end - else - begin - Cmd := FindCommand(Tokens[0]); - if not Assigned(Cmd) then - begin - // Complete top-level commands by prefix - toComplete := Tokens[0]; - for i := 0 to FCommands.Count - 1 do - if StartsStr(LowerCase(toComplete), LowerCase(FCommands[i].Name)) then - suggestions.Add(FCommands[i].Name); - Result := suggestions; - Exit; - end; - idx := 1; - end; - - // Walk subcommands - while (idx < tc) and (not StartsStr('-', Tokens[idx])) do - begin - SubCmd := nil; - for SCmd in Cmd.SubCommands do - if SameText(SCmd.Name, Tokens[idx]) then - begin - SubCmd := SCmd; - Break; - end; - if Assigned(SubCmd) then - begin - Cmd := SubCmd; - Inc(idx); - end - else - Break; - end; - - // Build command path string from tokens used to reach Cmd (for matching registrations) - pathParts := TStringList.Create; - try - if idx > 0 then - pathParts.Add(Tokens[0]); - for j := 1 to idx - 1 do - pathParts.Add(Tokens[j]); - // cmdPath not used here explicitly by name; lookups happen in helper calls below - finally - pathParts.Free; - end; - - // Determine completion context - // If last token starts with '-', either complete flag names or flag values - if (tc > 0) and (Tokens[tc - 1] <> '') and (StartsStr('-', Tokens[tc - 1])) then - begin - toComplete := Tokens[tc - 1]; - // Check if it's a complete flag match (for PowerShell which doesn't pass empty args) - Param := ParamByFlag(Cmd, toComplete); - if Assigned(Param) and ((Param.ParamType = ptBoolean) or (Param.ParamType = ptEnum)) then - begin - // Complete values for this flag - if Param.ParamType = ptBoolean then - begin - suggestions.Add('true'); - suggestions.Add('false'); - directive := directive or CD_NOFILE; - end - else if Param.ParamType = ptEnum then - begin - vals := TStringList.Create; - try - vals.Delimiter := '|'; - vals.DelimitedText := Param.AllowedValues; - for j := 0 to vals.Count - 1 do suggestions.Add(vals[j]); - directive := directive or CD_NOFILE; - finally - vals.Free; - end; - end; - end - else - begin - // Complete flag names - for i := 0 to Length(Cmd.Parameters) - 1 do - begin - if StartsStr(LowerCase(toComplete), LowerCase(Cmd.Parameters[i].LongFlag)) then - suggestions.Add(Cmd.Parameters[i].LongFlag); - if (Cmd.Parameters[i].ShortFlag <> '') and StartsStr(LowerCase(toComplete), LowerCase(Cmd.Parameters[i].ShortFlag)) then - suggestions.Add(Cmd.Parameters[i].ShortFlag); - end; - // global flags - if StartsStr(LowerCase(toComplete), '--help') then suggestions.Add('--help'); - if StartsStr(LowerCase(toComplete), '-h') then suggestions.Add('-h'); - if StartsStr(LowerCase(toComplete), '--version') then suggestions.Add('--version'); - if StartsStr(LowerCase(toComplete), '-v') then suggestions.Add('-v'); - if Cmd = FRootCommand then - begin - if StartsStr(LowerCase(toComplete), '--help-complete') then - suggestions.Add('--help-complete'); - if StartsStr(LowerCase(toComplete), '--completion-file') then - suggestions.Add('--completion-file'); - if StartsStr(LowerCase(toComplete), '--completion-file-pwsh') then - suggestions.Add('--completion-file-pwsh'); - end; - end; - end - else - begin - // Check if we're completing a flag value: previous token is a flag - if (tc >= 2) and (StartsStr('-', Tokens[tc - 2])) and (not StartsStr('-', Tokens[tc - 1])) then - begin - toComplete := Tokens[tc - 1]; - Param := ParamByFlag(Cmd, Tokens[tc - 2]); - if Assigned(Param) then - begin - // If user registered a hook, call it - pathParts := TStringList.Create; - try - if idx > 0 then - pathParts.Add(Tokens[0]); - for j := 1 to idx - 1 do - pathParts.Add(Tokens[j]); - cmdPath := Trim(pathParts.Text); - cmdPath := StringReplace(cmdPath, sLineBreak, ' ', [rfReplaceAll]); - finally - pathParts.Free; - end; - - if GetRegisteredFlagCompletion(cmdPath, Tokens[tc - 2], Hook) then - begin - posList := TStringList.Create; - try - for j := idx to tc - 3 do // exclude the flag and its value - if not StartsStr('-', Tokens[j]) then - posList.Add(Tokens[j]); - SetLength(argsArr, posList.Count); - for j := 0 to posList.Count - 1 do argsArr[j] := posList[j]; - finally - posList.Free; - end; - res := Hook(argsArr, toComplete); - for j := 0 to Length(res) - 1 do suggestions.Add(res[j]); - directive := directive or CD_NOFILE; - end - else - begin - // Built-in suggestions for boolean or enum - if Param.ParamType = ptBoolean then - begin - suggestions.Add('true'); - suggestions.Add('false'); - directive := directive or CD_NOFILE; - end - else if Param.ParamType = ptEnum then - begin - vals := TStringList.Create; - try - vals.Delimiter := '|'; - vals.DelimitedText := Param.AllowedValues; - for j := 0 to vals.Count - 1 do suggestions.Add(vals[j]); - directive := directive or CD_NOFILE; - finally - vals.Free; - end; - end; - end; - end; - end - else - begin - // Positional completion: count how many positional args already present - posArgs := TStringList.Create; - try - // scan tokens from idx to tc-1 (exclude the token being completed if non-empty) - j := idx; - while j <= tc - 1 - Ord(not isNewToken) do - begin - if not StartsStr('-', Tokens[j]) and (Tokens[j] <> '') then - posArgs.Add(Tokens[j]); - // skip flag values - if StartsStr('-', Tokens[j]) and (j + 1 <= tc - 1) and (not StartsStr('-', Tokens[j+1])) then - Inc(j); - Inc(j); - end; - - argIndex := posArgs.Count; // zero-based index for the current positional - - // Prepare args array for callback (already-entered positional args) - SetLength(argsArr2, posArgs.Count); - for j := 0 to posArgs.Count - 1 do argsArr2[j] := posArgs[j]; - - cmdPath := ''; - pathBuilder := TStringList.Create; - try - if idx > 0 then - begin - pathBuilder.Add(Tokens[0]); - for j := 1 to idx - 1 do - pathBuilder.Add(Tokens[j]); - end; - cmdPath := Trim(pathBuilder.Text); - cmdPath := StringReplace(cmdPath, sLineBreak, ' ', [rfReplaceAll]); - finally - pathBuilder.Free; - end; - - if GetRegisteredPositionalCompletion(cmdPath, argIndex, PosHook) then - begin - res := PosHook(argsArr2, Tokens[tc - 1]); - for j := 0 to Length(res) - 1 do suggestions.Add(res[j]); - directive := directive or CD_NOFILE; - end - else - begin - // No hook: suggest subcommands and flags - if argIndex = 0 then - begin - // Suggest subcommands first - for j := 0 to Length(Cmd.SubCommands) - 1 do - suggestions.Add(Cmd.SubCommands[j].Name); - // Then suggest flags - for j := 0 to Length(Cmd.Parameters) - 1 do - begin - suggestions.Add(Cmd.Parameters[j].LongFlag); - if Cmd.Parameters[j].ShortFlag <> '' then - suggestions.Add(Cmd.Parameters[j].ShortFlag); - end; - // Add global flags - suggestions.Add('--help'); - suggestions.Add('-h'); - end; - end; - finally - posArgs.Free; - end; - end; - end; - - // append directive line - suggestions.Add(':'+IntToStr(directive)); - Result := suggestions; + Result := CompleteCLI(Tokens, FRootCommand, CommandSnapshot); end; procedure TCLIApplication.HandleCompletion; @@ -1601,6 +1028,7 @@ procedure TCLIApplication.HandleCompletion; outList: TStringList; begin // Build tokens after the hidden __complete argument. + Tokens := nil; SetLength(Tokens, ArgumentCount - 1); for i := 0 to ArgumentCount - 2 do Tokens[i] := ArgumentAt(i + 2); diff --git a/src/cli.command.pas b/src/cli.command.pas index 37c9083..0977fd4 100644 --- a/src/cli.command.pas +++ b/src/cli.command.pas @@ -51,6 +51,8 @@ TBaseCommand = class(TInterfacedObject, ICommand, ICommandParameterReceiver) { Shows help text for this command Displays usage, description, parameters, and examples } procedure ShowHelp; + procedure WriteHelpLine(const Text: string; const Color: TConsoleColor; + const UseColor: Boolean); public { Creates new command instance @param AName Command name as used in CLI @@ -208,6 +210,9 @@ TBaseCommand = class(TInterfacedObject, ICommand, ICommandParameterReceiver) implementation +uses + CLI.Internal.ParameterValues, CLI.Internal.Help; + { Constructor: Creates new command instance @param AName Command name as used in CLI @param ADescription Command description for help text } @@ -392,8 +397,6 @@ procedure TBaseCommand.SetParsedParams(const Params: TStringList); function TBaseCommand.GetParameterValue(const Flag: string; out Value: string): Boolean; var Param: ICommandParameter; - idx: Integer; - paramVal: string; begin Result := False; if not Assigned(FParsedParams) then @@ -404,59 +407,8 @@ function TBaseCommand.GetParameterValue(const Flag: string; out Value: string): begin if (Param.LongFlag = Flag) or (Param.ShortFlag = Flag) then begin - // Special handling for boolean flags - if Param.ParamType = ptBoolean then - begin - idx := FParsedParams.IndexOfName(Param.LongFlag); - if idx = -1 then - idx := FParsedParams.IndexOfName(Param.ShortFlag); - if idx <> -1 then - begin - paramVal := FParsedParams.ValueFromIndex[idx]; - if (paramVal = '') then - begin - Value := 'true'; // flag present, no value - Result := True; - Exit; - end - else if SameText(paramVal, 'true') or SameText(paramVal, 'false') then - begin - Value := paramVal; - Result := True; - Exit; - end - else - begin - Value := paramVal; - Result := True; - Exit; - end; - end - else if Param.DefaultValue <> '' then - begin - Value := Param.DefaultValue; - Result := True; - Exit; - end - else - begin - Value := 'false'; - Result := False; - Exit; - end; - end; - // Check both long and short flags in parsed parameters - Value := FParsedParams.Values[Param.LongFlag]; - if Value = '' then - Value := FParsedParams.Values[Param.ShortFlag]; - if Value <> '' then - Exit(True) - else if Param.DefaultValue <> '' then - begin - Value := Param.DefaultValue; - Exit(True); - end; - Break; + Result := TryGetParameterValue(Param, FParsedParams, Value); + Exit; end; end; end; @@ -470,15 +422,11 @@ function TBaseCommand.GetParameterValue(const Flag: string; out Value: string): - Examples } procedure TBaseCommand.ShowHelp; var - Param: ICommandParameter; - SubCmd: ICommand; - RequiredText: string; - ExeName: string; CommandPath: string; i: Integer; + Renderer: TCLIHelpRenderer; + Commands: TCommandArray; begin - ExeName := ExtractFileName(ParamStr(0)); - // Build full command path CommandPath := ''; for i := 1 to ParamCount do @@ -491,49 +439,27 @@ procedure TBaseCommand.ShowHelp; end; if CommandPath = '' then CommandPath := Name; - - // Show usage and description - TConsole.WriteLn('Usage: ' + ExeName + ' ' + CommandPath + ' [options]'); - TConsole.WriteLn(''); - TConsole.WriteLn(Description); - - // Show subcommands if any - if Length(SubCommands) > 0 then - begin - TConsole.WriteLn(''); - TConsole.WriteLn('Commands:', ccCyan); - for SubCmd in SubCommands do - TConsole.WriteLn(' ' + PadRight(SubCmd.Name, 15) + SubCmd.Description); - - TConsole.WriteLn(''); - TConsole.WriteLn('Examples:', ccCyan); - TConsole.WriteLn(' ' + ExeName + ' ' + CommandPath + ' --help'); - TConsole.WriteLn(' Show help for a specific command'); - for SubCmd in SubCommands do - TConsole.WriteLn(' ' + ExeName + ' ' + CommandPath + ' ' + SubCmd.Name + ' --help'); - end; - - // Show parameters if any - if Length(Parameters) > 0 then - begin - TConsole.WriteLn(''); - TConsole.WriteLn('Options:', ccCyan); - for Param in Parameters do - begin - if Param.Required then - RequiredText := ' (required)' - else - RequiredText := ''; - - TConsole.WriteLn(' ' + Param.ShortFlag + ', ' + PadRight(Param.LongFlag, 20) + - Param.Description + RequiredText); - - if Param.DefaultValue <> '' then - TConsole.WriteLn(' Default: ' + Param.DefaultValue); - end; + + Commands := nil; + Renderer := TCLIHelpRenderer.Create('', '', ExtractFileName(ParamStr(0)), + nil, Commands, @WriteHelpLine); + try + Renderer.ShowCommandDetails(Description, Parameters, SubCommands, + CommandPath, chsCommand); + finally + Renderer.Free; end; end; +procedure TBaseCommand.WriteHelpLine(const Text: string; + const Color: TConsoleColor; const UseColor: Boolean); +begin + if UseColor then + TConsole.WriteLn(Text, Color) + else + TConsole.WriteLn(Text); +end; + end. diff --git a/src/cli.interfaces.pas b/src/cli.interfaces.pas index 88bc5a8..639b712 100644 --- a/src/cli.interfaces.pas +++ b/src/cli.interfaces.pas @@ -18,7 +18,7 @@ interface ptEnum, // Enumerated value (e.g., --log-level debug|info|warn|error) ptDateTime, // Date/time value (e.g., --start "2024-01-01 12:00") ptArray, // Comma-separated list (e.g., --tags tag1,tag2,tag3) - ptPassword, // Sensitive value stored as a string; no automatic redaction + ptPassword, // Sensitive string; framework debug output redacts its value ptUrl // URL value with format validation (e.g., --repo https://github.com/user/repo) ); diff --git a/src/cli.internal.completion.pas b/src/cli.internal.completion.pas new file mode 100644 index 0000000..8b2c4ec --- /dev/null +++ b/src/cli.internal.completion.pas @@ -0,0 +1,307 @@ +unit CLI.Internal.Completion; + +{$mode objfpc}{$H+}{$J-} + +interface + +uses + Classes, CLI.Interfaces; + +function CompleteCLI(const Tokens: array of string; + const RootCommand: ICommand; + const Commands: array of ICommand): TStringList; + +implementation + +uses + SysUtils, StrUtils, CLI.Internal.ParameterValues; + +const + CD_NOFILE = 4; + +type + TCommandArray = specialize TArray; + + TCLICompletionEngine = class + private + FRootCommand: ICommand; + FCommands: TCommandArray; + function FindCommand(const Name: string): ICommand; + procedure AddGlobalFlags(const Suggestions: TStrings; + const Prefix: string; const IncludeExtended, Standalone: Boolean); + procedure AddCommandFlags(const Suggestions: TStrings; + const Command: ICommand; const Prefix: string); + procedure AddParameterValues(const Suggestions: TStrings; + const Param: ICommandParameter; var Directive: Integer); + function ResolveCommand(const Tokens: array of string; + out Command: ICommand; out ParameterIndex: Integer): Boolean; + procedure CompleteFlag(const Tokens: array of string; + const Command: ICommand; const Suggestions: TStrings; + var Directive: Integer); + procedure CompleteValueOrPosition(const Tokens: array of string; + const Command: ICommand; const ParameterIndex: Integer; + const Suggestions: TStrings; var Directive: Integer); + public + constructor Create(const RootCommand: ICommand; + const Commands: array of ICommand); + function Complete(const Tokens: array of string): TStringList; + end; + +constructor TCLICompletionEngine.Create(const RootCommand: ICommand; + const Commands: array of ICommand); +var + i: Integer; +begin + inherited Create; + FRootCommand := RootCommand; + SetLength(FCommands, Length(Commands)); + for i := 0 to Length(Commands) - 1 do + FCommands[i] := Commands[i]; +end; + +function TCLICompletionEngine.FindCommand(const Name: string): ICommand; +var + Command: ICommand; +begin + Result := nil; + for Command in FCommands do + if SameText(Command.Name, Name) then + Exit(Command); +end; + +procedure TCLICompletionEngine.AddGlobalFlags(const Suggestions: TStrings; + const Prefix: string; const IncludeExtended, Standalone: Boolean); +begin + if Standalone then + begin + if StartsStr(LowerCase(Prefix), '--help') then Suggestions.Add('--help'); + if StartsStr(LowerCase(Prefix), '--help-complete') then + Suggestions.Add('--help-complete'); + if StartsStr(LowerCase(Prefix), '--version') then + Suggestions.Add('--version'); + if StartsStr(LowerCase(Prefix), '--completion-file') then + Suggestions.Add('--completion-file'); + if StartsStr(LowerCase(Prefix), '--completion-file-pwsh') then + Suggestions.Add('--completion-file-pwsh'); + if StartsStr(LowerCase(Prefix), '-h') then Suggestions.Add('-h'); + if StartsStr(LowerCase(Prefix), '-v') then Suggestions.Add('-v'); + Exit; + end; + + if StartsStr(LowerCase(Prefix), '--help') then Suggestions.Add('--help'); + if StartsStr(LowerCase(Prefix), '-h') then Suggestions.Add('-h'); + if StartsStr(LowerCase(Prefix), '--version') then Suggestions.Add('--version'); + if StartsStr(LowerCase(Prefix), '-v') then Suggestions.Add('-v'); + if IncludeExtended then + begin + if StartsStr(LowerCase(Prefix), '--help-complete') then + Suggestions.Add('--help-complete'); + if StartsStr(LowerCase(Prefix), '--completion-file') then + Suggestions.Add('--completion-file'); + if StartsStr(LowerCase(Prefix), '--completion-file-pwsh') then + Suggestions.Add('--completion-file-pwsh'); + end; +end; + +procedure TCLICompletionEngine.AddCommandFlags(const Suggestions: TStrings; + const Command: ICommand; const Prefix: string); +var + Param: ICommandParameter; +begin + for Param in Command.Parameters do + begin + if StartsStr(LowerCase(Prefix), LowerCase(Param.LongFlag)) then + Suggestions.Add(Param.LongFlag); + if (Param.ShortFlag <> '') and + StartsStr(LowerCase(Prefix), LowerCase(Param.ShortFlag)) then + Suggestions.Add(Param.ShortFlag); + end; + AddGlobalFlags(Suggestions, Prefix, Command = FRootCommand, False); +end; + +procedure TCLICompletionEngine.AddParameterValues(const Suggestions: TStrings; + const Param: ICommandParameter; var Directive: Integer); +var + Values: TStringList; + i: Integer; +begin + if not Assigned(Param) then + Exit; + if Param.ParamType = ptBoolean then + begin + Suggestions.Add('true'); + Suggestions.Add('false'); + end + else if Param.ParamType = ptEnum then + begin + Values := TStringList.Create; + try + Values.Delimiter := '|'; + Values.DelimitedText := Param.AllowedValues; + for i := 0 to Values.Count - 1 do + Suggestions.Add(Values[i]); + finally + Values.Free; + end; + end + else + Exit; + Directive := Directive or CD_NOFILE; +end; + +function TCLICompletionEngine.ResolveCommand(const Tokens: array of string; + out Command: ICommand; out ParameterIndex: Integer): Boolean; +var + Candidate, SubCommand: ICommand; +begin + if StartsStr('-', Tokens[0]) then + begin + Command := FRootCommand; + ParameterIndex := 0; + end + else + begin + Command := FindCommand(Tokens[0]); + ParameterIndex := 1; + end; + + Result := Assigned(Command); + if not Result then + Exit; + + while (ParameterIndex < Length(Tokens)) and + not StartsStr('-', Tokens[ParameterIndex]) do + begin + SubCommand := nil; + for Candidate in Command.SubCommands do + if SameText(Candidate.Name, Tokens[ParameterIndex]) then + begin + SubCommand := Candidate; + Break; + end; + if not Assigned(SubCommand) then + Break; + Command := SubCommand; + Inc(ParameterIndex); + end; +end; + +procedure TCLICompletionEngine.CompleteFlag(const Tokens: array of string; + const Command: ICommand; const Suggestions: TStrings; + var Directive: Integer); +var + Current: string; + Param: ICommandParameter; +begin + Current := Tokens[High(Tokens)]; + Param := FindParameterByFlag(Command, Current); + if Assigned(Param) and + (Param.ParamType in [ptBoolean, ptEnum]) then + AddParameterValues(Suggestions, Param, Directive) + else + AddCommandFlags(Suggestions, Command, Current); +end; + +procedure TCLICompletionEngine.CompleteValueOrPosition( + const Tokens: array of string; const Command: ICommand; + const ParameterIndex: Integer; const Suggestions: TStrings; + var Directive: Integer); +var + i, PositionalCount, LastIndex: Integer; + Param: ICommandParameter; + SubCommand: ICommand; +begin + LastIndex := High(Tokens); + if (Length(Tokens) >= 2) and StartsStr('-', Tokens[LastIndex - 1]) and + not StartsStr('-', Tokens[LastIndex]) then + begin + Param := FindParameterByFlag(Command, Tokens[LastIndex - 1]); + AddParameterValues(Suggestions, Param, Directive); + Exit; + end; + + PositionalCount := 0; + i := ParameterIndex; + while i <= LastIndex - Ord(Tokens[LastIndex] <> '') do + begin + if not StartsStr('-', Tokens[i]) and (Tokens[i] <> '') then + Inc(PositionalCount); + if StartsStr('-', Tokens[i]) and (i + 1 <= LastIndex) and + not StartsStr('-', Tokens[i + 1]) then + Inc(i); + Inc(i); + end; + + if PositionalCount <> 0 then + Exit; + for SubCommand in Command.SubCommands do + Suggestions.Add(SubCommand.Name); + for Param in Command.Parameters do + begin + Suggestions.Add(Param.LongFlag); + if Param.ShortFlag <> '' then + Suggestions.Add(Param.ShortFlag); + end; + Suggestions.Add('--help'); + Suggestions.Add('-h'); +end; + +function TCLICompletionEngine.Complete( + const Tokens: array of string): TStringList; +var + Command: ICommand; + ParameterIndex, Directive: Integer; + Current: string; +begin + Result := TStringList.Create; + Result.Duplicates := dupIgnore; + Result.Sorted := False; + Directive := 0; + + if Length(Tokens) = 0 then + begin + for Command in FCommands do + Result.Add(Command.Name); + Exit; + end; + + if StartsStr('-', Tokens[0]) and not Assigned(FRootCommand) then + begin + AddGlobalFlags(Result, Tokens[0], True, True); + Result.Add(':' + IntToStr(Directive)); + Exit; + end; + + if not ResolveCommand(Tokens, Command, ParameterIndex) then + begin + Current := Tokens[0]; + for Command in FCommands do + if StartsStr(LowerCase(Current), LowerCase(Command.Name)) then + Result.Add(Command.Name); + Exit; + end; + + Current := Tokens[High(Tokens)]; + if (Current <> '') and StartsStr('-', Current) then + CompleteFlag(Tokens, Command, Result, Directive) + else + CompleteValueOrPosition(Tokens, Command, ParameterIndex, Result, + Directive); + Result.Add(':' + IntToStr(Directive)); +end; + +function CompleteCLI(const Tokens: array of string; + const RootCommand: ICommand; + const Commands: array of ICommand): TStringList; +var + Engine: TCLICompletionEngine; +begin + Engine := TCLICompletionEngine.Create(RootCommand, Commands); + try + Result := Engine.Complete(Tokens); + finally + Engine.Free; + end; +end; + +end. diff --git a/src/cli.internal.help.pas b/src/cli.internal.help.pas new file mode 100644 index 0000000..50fa597 --- /dev/null +++ b/src/cli.internal.help.pas @@ -0,0 +1,311 @@ +unit CLI.Internal.Help; + +{$mode objfpc}{$H+}{$J-} + +interface + +uses + SysUtils, StrUtils, CLI.Interfaces, CLI.Console; + +type + TCommandArray = specialize TArray; + TCLIHelpWriter = procedure(const Text: string; const Color: TConsoleColor; + const UseColor: Boolean) of object; + TCommandHelpStyle = (chsApplication, chsCommand); + + TCLIHelpRenderer = class + private + FName: string; + FVersion: string; + FExecutableName: string; + FRootCommand: ICommand; + FCommands: TCommandArray; + FWriter: TCLIHelpWriter; + procedure WriteLine(const Text: string); overload; + procedure WriteLine(const Text: string; + const Color: TConsoleColor); overload; + procedure WriteParameters(const Parameters: array of ICommandParameter; + const Indent: string); + procedure ShowCommandExamples(const SubCommands: array of ICommand; + const CommandPath: string; const Style: TCommandHelpStyle); + procedure ShowCompleteCommand(const Command: ICommand; + const Indent: string); + public + constructor Create(const AName, AVersion, AExecutableName: string; + const ARootCommand: ICommand; const ACommands: TCommandArray; + const AWriter: TCLIHelpWriter); + procedure ShowGeneral; + procedure ShowCommand(const Command: ICommand; const CommandPath: string; + const Style: TCommandHelpStyle = chsApplication); + procedure ShowCommandDetails(const Description: string; + const Parameters: array of ICommandParameter; + const SubCommands: array of ICommand; const CommandPath: string; + const Style: TCommandHelpStyle = chsApplication); + procedure ShowComplete; + procedure ShowBrief; + end; + +implementation + +constructor TCLIHelpRenderer.Create(const AName, AVersion, + AExecutableName: string; const ARootCommand: ICommand; + const ACommands: TCommandArray; const AWriter: TCLIHelpWriter); +begin + inherited Create; + FName := AName; + FVersion := AVersion; + FExecutableName := AExecutableName; + FRootCommand := ARootCommand; + FCommands := Copy(ACommands); + FWriter := AWriter; +end; + +procedure TCLIHelpRenderer.WriteLine(const Text: string); +begin + FWriter(Text, ccWhite, False); +end; + +procedure TCLIHelpRenderer.WriteLine(const Text: string; + const Color: TConsoleColor); +begin + FWriter(Text, Color, True); +end; + +procedure TCLIHelpRenderer.WriteParameters( + const Parameters: array of ICommandParameter; const Indent: string); +var + Param: ICommandParameter; + RequiredText: string; +begin + for Param in Parameters do + begin + if Param.Required then + RequiredText := ' (required)' + else + RequiredText := ''; + WriteLine(Indent + Param.ShortFlag + ', ' + + PadRight(Param.LongFlag, 20) + Param.Description + RequiredText); + if Param.DefaultValue <> '' then + WriteLine(Indent + ' Default: ' + Param.DefaultValue); + end; +end; + +procedure TCLIHelpRenderer.ShowGeneral; +var + Command: ICommand; +begin + WriteLine(FName + ' version ' + FVersion); + WriteLine(''); + WriteLine('Usage:', ccCyan); + if Assigned(FRootCommand) then + begin + WriteLine(' ' + FExecutableName + ' [options]'); + if Length(FCommands) > 0 then + WriteLine(' ' + FExecutableName + ' [options]'); + end + else + WriteLine(' ' + FExecutableName + ' [options]'); + WriteLine(''); + + if Assigned(FRootCommand) and (FRootCommand.Description <> '') then + begin + WriteLine(FRootCommand.Description); + WriteLine(''); + end; + + if Assigned(FRootCommand) and (Length(FRootCommand.Parameters) > 0) then + begin + WriteLine('Options:', ccCyan); + WriteParameters(FRootCommand.Parameters, ' '); + WriteLine(''); + end; + + if Length(FCommands) > 0 then + begin + WriteLine('Commands:', ccCyan); + for Command in FCommands do + WriteLine(' ' + PadRight(Command.Name, 15) + Command.Description); + WriteLine(''); + end; + + WriteLine('Global Options:', ccCyan); + WriteLine(' -h, --help Show this help message'); + WriteLine(' --help-complete Show complete reference for all commands'); + WriteLine(' --completion-file Output Bash completion script (redirect to a file)'); + WriteLine(' --completion-file-pwsh Output PowerShell completion script (redirect to a .ps1 file)'); + WriteLine(' -v, --version Show version information'); + WriteLine(''); + + if Length(FCommands) > 0 then + begin + WriteLine('Examples:', ccCyan); + WriteLine(' Get help for commands:'); + WriteLine(' ' + FExecutableName + ' --help'); + WriteLine(''); + WriteLine(' Available command help:'); + for Command in FCommands do + WriteLine(' ' + FExecutableName + ' ' + Command.Name + ' --help'); + WriteLine(''); + end; +end; + +procedure TCLIHelpRenderer.ShowCommand(const Command: ICommand; + const CommandPath: string; const Style: TCommandHelpStyle); +begin + ShowCommandDetails(Command.Description, Command.Parameters, + Command.SubCommands, CommandPath, Style); +end; + +procedure TCLIHelpRenderer.ShowCommandDetails(const Description: string; + const Parameters: array of ICommandParameter; + const SubCommands: array of ICommand; const CommandPath: string; + const Style: TCommandHelpStyle); +var + SubCommand: ICommand; +begin + WriteLine('Usage: ' + FExecutableName + ' ' + CommandPath + ' [options]'); + WriteLine(''); + WriteLine(Description); + + if Length(SubCommands) > 0 then + begin + WriteLine(''); + WriteLine('Commands:', ccCyan); + for SubCommand in SubCommands do + WriteLine(' ' + PadRight(SubCommand.Name, 15) + SubCommand.Description); + end; + + if (Style = chsCommand) and (Length(SubCommands) > 0) then + ShowCommandExamples(SubCommands, CommandPath, Style); + + if Length(Parameters) > 0 then + begin + WriteLine(''); + WriteLine('Options:', ccCyan); + WriteParameters(Parameters, ' '); + end; + + if (Style = chsApplication) and (Length(SubCommands) > 0) then + ShowCommandExamples(SubCommands, CommandPath, Style); +end; + +procedure TCLIHelpRenderer.ShowCommandExamples( + const SubCommands: array of ICommand; + const CommandPath: string; const Style: TCommandHelpStyle); +var + SubCommand: ICommand; +begin + WriteLine(''); + WriteLine('Examples:', ccCyan); + if Style = chsCommand then + begin + WriteLine(' ' + FExecutableName + ' ' + CommandPath + + ' --help'); + WriteLine(' Show help for a specific command'); + for SubCommand in SubCommands do + WriteLine(' ' + FExecutableName + ' ' + CommandPath + ' ' + + SubCommand.Name + ' --help'); + end + else + begin + WriteLine(' Get help for commands:'); + WriteLine(' ' + FExecutableName + ' ' + CommandPath + + ' --help'); + WriteLine(''); + WriteLine(' Available command help:'); + for SubCommand in SubCommands do + WriteLine(' ' + FExecutableName + ' ' + CommandPath + ' ' + + SubCommand.Name + ' --help'); + WriteLine(''); + end; +end; + +procedure TCLIHelpRenderer.ShowCompleteCommand(const Command: ICommand; + const Indent: string); +var + SubCommand: ICommand; +begin + WriteLine(Indent + Command.Name + ' - ' + Command.Description); + if Length(Command.Parameters) > 0 then + begin + WriteLine(''); + WriteLine(Indent + 'OPTIONS:', ccCyan); + WriteParameters(Command.Parameters, Indent + ' '); + end; + + if Length(Command.SubCommands) > 0 then + begin + WriteLine(''); + WriteLine(Indent + 'SUBCOMMANDS:', ccCyan); + for SubCommand in Command.SubCommands do + begin + ShowCompleteCommand(SubCommand, Indent + ' '); + WriteLine(''); + end; + end; +end; + +procedure TCLIHelpRenderer.ShowComplete; +var + i: Integer; +begin + WriteLine(FName + ' version ' + FVersion); + WriteLine(''); + WriteLine('DESCRIPTION', ccCyan); + if Assigned(FRootCommand) and (FRootCommand.Description <> '') then + WriteLine(' ' + FRootCommand.Description) + else + WriteLine(' Complete reference for all commands and options'); + WriteLine(''); + + if Assigned(FRootCommand) and (Length(FRootCommand.Parameters) > 0) then + begin + WriteLine('ROOT OPTIONS', ccCyan); + WriteParameters(FRootCommand.Parameters, ' '); + WriteLine(''); + end; + + WriteLine('GLOBAL OPTIONS', ccCyan); + WriteLine(' -h, --help Show command help'); + WriteLine(' --help-complete Show this complete reference'); + WriteLine(' --completion-file Output Bash completion script (use --completion-file > myapp-completion.sh)'); + WriteLine(' --completion-file-pwsh Output PowerShell completion script (use --completion-file-pwsh > myapp-completion.ps1)'); + WriteLine(' -v, --version Show version information'); + + if Length(FCommands) > 0 then + begin + WriteLine(''); + WriteLine('COMMANDS', ccCyan); + for i := 0 to Length(FCommands) - 1 do + begin + if i > 0 then + WriteLine(''); + ShowCompleteCommand(FCommands[i], ' '); + end; + WriteLine(''); + WriteLine('For more details on a specific command, use:'); + WriteLine(' ' + FExecutableName + ' --help'); + end; +end; + +procedure TCLIHelpRenderer.ShowBrief; +var + Command: ICommand; +begin + if Assigned(FRootCommand) then + begin + WriteLine('Usage: ' + FExecutableName + ' [options]'); + if Length(FCommands) > 0 then + WriteLine(' ' + FExecutableName + ' [options]'); + end + else + WriteLine('Usage: ' + FExecutableName + ' [options]'); + WriteLine(''); + WriteLine('Commands:', ccCyan); + for Command in FCommands do + WriteLine(' ' + PadRight(Command.Name, 15) + Command.Description); + WriteLine(''); + WriteLine('Use --help for more information.'); +end; + +end. diff --git a/src/cli.internal.parametervalues.pas b/src/cli.internal.parametervalues.pas new file mode 100644 index 0000000..f8a0183 --- /dev/null +++ b/src/cli.internal.parametervalues.pas @@ -0,0 +1,102 @@ +unit CLI.Internal.ParameterValues; + +{$mode objfpc}{$H+}{$J-} + +interface + +uses + Classes, SysUtils, CLI.Interfaces; + +function FindParameterByFlag(const Command: ICommand; + const Flag: string): ICommandParameter; +function TryGetParameterValue(const Param: ICommandParameter; + const ParsedParams: TStrings; out Value: string): Boolean; +function RedactParameterValue(const Command: ICommand; const Flag, + Value: string): string; +function RedactArgument(const Command: ICommand; + const Argument: string): string; + +implementation + +function FindParameterByFlag(const Command: ICommand; + const Flag: string): ICommandParameter; +var + Param: ICommandParameter; +begin + Result := nil; + if not Assigned(Command) then + Exit; + + for Param in Command.Parameters do + if SameText(Param.LongFlag, Flag) or SameText(Param.ShortFlag, Flag) then + Exit(Param); +end; + +function TryGetParameterValue(const Param: ICommandParameter; + const ParsedParams: TStrings; out Value: string): Boolean; +var + Index: Integer; +begin + Result := False; + Value := ''; + if not Assigned(Param) or not Assigned(ParsedParams) then + Exit; + + Index := ParsedParams.IndexOfName(Param.LongFlag); + if Index = -1 then + Index := ParsedParams.IndexOfName(Param.ShortFlag); + + if Index <> -1 then + begin + Value := ParsedParams.ValueFromIndex[Index]; + if Param.ParamType = ptBoolean then + begin + if Value = '' then + Value := 'true'; + Exit(True); + end; + if Value <> '' then + Exit(True); + end; + + if Param.DefaultValue <> '' then + begin + Value := Param.DefaultValue; + Exit(True); + end; + + if Param.ParamType = ptBoolean then + Value := 'false'; +end; + +function RedactParameterValue(const Command: ICommand; const Flag, + Value: string): string; +var + Param: ICommandParameter; +begin + Param := FindParameterByFlag(Command, Flag); + if Assigned(Param) and (Param.ParamType = ptPassword) then + Result := '[REDACTED]' + else + Result := Value; +end; + +function RedactArgument(const Command: ICommand; + const Argument: string): string; +var + SeparatorPos: Integer; + Flag: string; + Param: ICommandParameter; +begin + Result := Argument; + SeparatorPos := Pos('=', Argument); + if SeparatorPos = 0 then + Exit; + + Flag := Copy(Argument, 1, SeparatorPos - 1); + Param := FindParameterByFlag(Command, Flag); + if Assigned(Param) and (Param.ParamType = ptPassword) then + Result := Flag + '=[REDACTED]'; +end; + +end. diff --git a/tasks/plan.md b/tasks/plan.md index f54fe4f..d572763 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -3,8 +3,8 @@ ## Overview Deliver the ROADMAP.md v1.3.3 stabilization work only: safe example cleanup, -behavioural test coverage for help and numeric parsing, CI compilation of the -seven canonical examples, and documentation of the changed behaviour. +behavioural coverage, hermetic builds, safe diagnostics, and behaviour- +preserving internal decomposition before the next public API is added. ## Architecture Decisions @@ -14,6 +14,11 @@ seven canonical examples, and documentation of the changed behaviour. adding a method to the public `ICLIApplication` API. - Extend the existing parser and validation path for separated signed numbers; do not create a parallel parsing path. +- Preserve `TCLIApplication` and all public compatibility symbols while moving + help rendering, completion calculation, and parameter-value semantics into + focused internal units. +- Keep deprecated completion callback registration as public no-ops for 1.x, + but remove private branches that can never execute. ## Task List @@ -34,12 +39,28 @@ seven canonical examples, and documentation of the changed behaviour. - [x] Task 4: Compile all seven canonical examples in Linux and Windows CI, document the behavioural changes, and run the release verification suite. +### Phase 4: Review hardening + +- [x] Task 5: Make framework test compilation hermetic and exclude capture + state and entry points from normal runtime builds. +- [x] Task 6: Characterize debug output and redact registered password values. +- [x] Task 7: Single-source parameter lookup and help rendering behind the + unchanged public facade. +- [x] Task 8: Characterize and extract completion calculation, deleting + unreachable private callback paths and unused allocations. +- [ ] Task 9: Decompose application dispatch into focused internal helpers, + then run the complete cross-platform release verification. + ### Checkpoint: Complete - [x] Windows cleanup smoke check passes; CI runs the platform-native checks. - [x] Framework and generator tests pass on Windows. - [x] All seven examples compile locally on Windows; both CI jobs run the check. - [x] No public API was added or changed. +- [x] Normal builds contain no capture-specific state or entry point. +- [x] Debug output cannot reveal registered password values. +- [x] Internal decomposition preserves all characterized behaviour and public + compatibility symbols. ## Risks and Mitigations @@ -48,8 +69,12 @@ seven canonical examples, and documentation of the changed behaviour. | Cleanup removes user content | High | Allowlist only generated compiler extensions and dedicated generated directories; assert tracked paths after cleanup. | | Output capture changes runtime output | High | Keep it internal, disabled by default, and test the normal help execution path. | | Signed numbers weaken option detection | High | Accept a leading `-` only when the registered parameter is integer or float and the candidate validates as numeric. | +| Internal refactoring changes observable output | High | Add characterization tests first and verify each extraction independently. | +| Stale compiler units bypass test defines | High | Force an isolated rebuild in both platform test runners. | +| Debug diagnostics expose secrets | High | Resolve parameter metadata before logging and redact password values in every debug form. | ## Scope Guard No new command API, parameter kinds, generator features, completion features, -or broad application refactor is included. +or public API removal is included. Public compatibility no-ops and test-oriented +members remain until the planned v2.0.0 cleanup. diff --git a/tasks/todo.md b/tasks/todo.md index 9ac87cb..9124f05 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -43,3 +43,58 @@ **Verification:** CI-script inspection and local compilation where available. **Dependencies:** Tasks 1โ€“3. + +## Task 5: Hermetic tests and production-safe capture + +**Acceptance criteria:** + +- [x] Windows and Linux runners force all units to rebuild in isolated output directories. +- [x] Capture-specific fields and methods do not exist in normal runtime builds. + +**Verification:** normal build followed by both framework runners; normal package build. + +**Dependencies:** Task 2. + +## Task 6: Password-safe debug diagnostics + +**Acceptance criteria:** + +- [x] Characterization covers ordinary debug output. +- [x] Separated and equals-form password values are replaced with `[REDACTED]` everywhere. + +**Verification:** focused framework tests. + +**Dependencies:** Task 5. + +## Task 7: Parameter and help consolidation + +**Acceptance criteria:** + +- [x] Application validation and command execution share one parameter-value implementation. +- [x] Application and base-command help share one renderer with unchanged observable output. + +**Verification:** framework characterization tests and generator compile suite. + +**Dependencies:** Tasks 5โ€“6. + +## Task 8: Completion extraction + +**Acceptance criteria:** + +- [x] Existing completion behavior is characterized before extraction. +- [x] Completion calculation lives outside `TCLIApplication`; unreachable private callback branches and unused allocations are deleted. + +**Verification:** focused completion tests and framework suite. + +**Dependencies:** Task 7. + +## Task 9: Dispatch decomposition and release verification + +**Acceptance criteria:** + +- [x] Application dispatch is composed from focused helpers without changing its public facade. +- [ ] Framework, generator, cleanup, package, and seven-example checks pass. + +**Verification:** complete Windows suite and Linux/Windows CI. + +**Dependencies:** Tasks 5โ€“8. diff --git a/tests/run_tests.ps1 b/tests/run_tests.ps1 index a70c0e9..8e3acb1 100644 --- a/tests/run_tests.ps1 +++ b/tests/run_tests.ps1 @@ -16,6 +16,7 @@ try { New-Item -ItemType Directory -Force -Path $UnitDir | Out-Null fpc ` + -B ` -dCLI_FP_TESTING ` "-Fu$RootDir\src" ` "-Fu$RootDir\tests" ` diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 9ce097a..3a8d312 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -8,6 +8,7 @@ trap 'rm -rf "$TMP_DIR"' EXIT mkdir -p "$TMP_DIR/units" fpc \ + -B \ -dCLI_FP_TESTING \ -Fu"$ROOT_DIR/src" \ -Fu"$ROOT_DIR/tests" \ diff --git a/tests/testcase.pas b/tests/testcase.pas index b910af3..5f86772 100644 --- a/tests/testcase.pas +++ b/tests/testcase.pas @@ -49,6 +49,7 @@ TCLIFrameworkTests = class(TTestCase) procedure Test_4_5_MultipleParameters; procedure Test_4_6_NegativeNumericValues; procedure Test_4_7_UnknownOptionStillFails; + procedure Test_4_8_DebugOutputRedactsPasswords; // 5.x - Help System Tests procedure Test_5_1_BasicHelp; @@ -73,6 +74,7 @@ TCLIFrameworkTests = class(TTestCase) procedure Test_7_6_InvalidRootParameterDoesNotExecute; procedure Test_7_7_CompleteRootParameters; procedure Test_7_8_NoRootPreservesEmptyArgumentBehavior; + procedure Test_7_9_CompletionBehaviour; end; implementation @@ -683,6 +685,47 @@ procedure TCLIFrameworkTests.Test_4_7_UnknownOptionStillFails; end; end; +procedure TCLIFrameworkTests.Test_4_8_DebugOutputRedactsPasswords; +var + Cmd: TRecordingCommand; + App: TCLIApplication; + Output: TStringList; +begin + Cmd := TRecordingCommand.Create('login', 'Authenticate a user'); + App := TCLIApplication.Create('TestApp', '1.3.3'); + Output := TStringList.Create; + try + Cmd.AddStringParameter('-u', '--user', 'User name', True); + Cmd.AddPasswordParameter('-p', '--password', 'Password', True); + App.RegisterCommand(Cmd); + App.DebugMode := True; + + AssertEquals('Separated password execution should succeed', 0, + App.TestExecuteAndCapture(MakeArgs([ + 'login', '--user', 'visible-user', '--password', 'separated-secret' + ]), Output)); + AssertTrue('Debug output should retain ordinary parameter values', + Pos('visible-user', Output.Text) > 0); + AssertTrue('Debug output should mark a redacted password', + Pos('[REDACTED]', Output.Text) > 0); + AssertEquals('Separated password must not appear in debug output', 0, + Pos('separated-secret', Output.Text)); + + Output.Clear; + AssertEquals('Equals-form password execution should succeed', 0, + App.TestExecuteAndCapture(MakeArgs([ + 'login', '--user=visible-user', '--password=equals-secret' + ]), Output)); + AssertTrue('Equals-form password should also be marked as redacted', + Pos('--password=[REDACTED]', Output.Text) > 0); + AssertEquals('Equals-form password must not appear in debug output', 0, + Pos('equals-secret', Output.Text)); + finally + Output.Free; + App.Free; + end; +end; + // 5.x - Help System Tests procedure TCLIFrameworkTests.Test_5_1_BasicHelp; @@ -1075,6 +1118,70 @@ procedure TCLIFrameworkTests.Test_7_8_NoRootPreservesEmptyArgumentBehavior; end; end; +procedure TCLIFrameworkTests.Test_7_9_CompletionBehaviour; +var + App: TCLIApplication; + Deploy, Target: TTestCommand; + Candidates: TStringList; +begin + App := TCLIApplication.Create('TestApp', '1.3.3'); + Deploy := TTestCommand.Create('deploy', 'Deploy an application'); + Target := TTestCommand.Create('target', 'Manage deployment targets'); + try + Deploy.AddFlag('-v', '--verbose', 'Verbose output'); + Deploy.AddEnumParameter('-m', '--mode', 'Deployment mode', + 'safe|fast'); + Deploy.AddSubCommand(Target); + App.RegisterCommand(Deploy); + + Candidates := App.TestComplete(MakeArgs([])); + try + AssertTrue('Empty completion should list top-level commands', + Candidates.IndexOf('deploy') >= 0); + finally + Candidates.Free; + end; + + Candidates := App.TestComplete(MakeArgs(['de'])); + try + AssertTrue('Command prefixes should be completed', + Candidates.IndexOf('deploy') >= 0); + finally + Candidates.Free; + end; + + Candidates := App.TestComplete(MakeArgs(['deploy', '--v'])); + try + AssertTrue('Command flags should be completed', + Candidates.IndexOf('--verbose') >= 0); + finally + Candidates.Free; + end; + + Candidates := App.TestComplete(MakeArgs(['deploy', '--mode', ''])); + try + AssertTrue('Enum values should be completed', + Candidates.IndexOf('safe') >= 0); + AssertTrue('Completion should include a directive', + Candidates.IndexOf(':' + IntToStr(CD_NOFILE)) >= 0); + finally + Candidates.Free; + end; + + Candidates := App.TestComplete(MakeArgs(['deploy', ''])); + try + AssertTrue('Subcommands should be completed', + Candidates.IndexOf('target') >= 0); + AssertTrue('Available flags should accompany subcommands', + Candidates.IndexOf('--mode') >= 0); + finally + Candidates.Free; + end; + finally + App.Free; + end; +end; + initialization RegisterTest(TCLIFrameworkTests); end. From ffd04d43be7a07761bf9e15c1000f22ebe5118f1 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 21:48:24 +1000 Subject: [PATCH 11/12] docs: record v1.3.3 verification --- docs/PULL_REQUEST_v1.3.3.md | 4 ++-- tasks/plan.md | 2 +- tasks/todo.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/PULL_REQUEST_v1.3.3.md b/docs/PULL_REQUEST_v1.3.3.md index 5c94078..32a3c6f 100644 --- a/docs/PULL_REQUEST_v1.3.3.md +++ b/docs/PULL_REQUEST_v1.3.3.md @@ -95,7 +95,7 @@ parameter kinds continue to require equals syntax. metadata at `1.3.3`. - [x] `git diff --check` passes. - [x] FPC 3.2.2. -- [ ] GitHub Actions on Windows and Linux after the PR is opened. +- [x] GitHub Actions on Windows and Linux after the PR is opened. ## Release Readiness @@ -103,6 +103,6 @@ parameter kinds continue to require equals syntax. - [x] Version metadata updated to `1.3.3`. - [x] Changelog and release notes prepared. - [x] Pull request notes prepared. -- [ ] Confirm GitHub Actions on Windows and Linux. +- [x] Confirm GitHub Actions on Windows and Linux. After merge, create the `v1.3.3` tag and publish the prepared release notes. diff --git a/tasks/plan.md b/tasks/plan.md index d572763..f471c89 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -48,7 +48,7 @@ preserving internal decomposition before the next public API is added. unchanged public facade. - [x] Task 8: Characterize and extract completion calculation, deleting unreachable private callback paths and unused allocations. -- [ ] Task 9: Decompose application dispatch into focused internal helpers, +- [x] Task 9: Decompose application dispatch into focused internal helpers, then run the complete cross-platform release verification. ### Checkpoint: Complete diff --git a/tasks/todo.md b/tasks/todo.md index 9124f05..b62d45b 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -93,7 +93,7 @@ **Acceptance criteria:** - [x] Application dispatch is composed from focused helpers without changing its public facade. -- [ ] Framework, generator, cleanup, package, and seven-example checks pass. +- [x] Framework, generator, cleanup, package, and seven-example checks pass. **Verification:** complete Windows suite and Linux/Windows CI. From 24559d1889cc544f4810695845059797e14c95b7 Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Thu, 13 Aug 2026 22:30:05 +1000 Subject: [PATCH 12/12] docs: finalize v1.3.3 release records --- CHANGELOG.md | 5 ++- README.md | 7 ++++ ROADMAP.md | 6 +-- docs/PULL_REQUEST_v1.3.3.md | 4 +- docs/README.md | 9 ++++- docs/RELEASE_NOTES_v1.3.3.md | 23 ++++++++--- docs/api-reference.md | 6 +++ docs/completion-testing/README.md | 9 +++-- docs/technical-docs.md | 63 +++++++++++++++++++++++-------- docs/test-output.md | 5 ++- docs/user-manual.md | 3 +- tasks/plan.md | 4 +- tasks/todo.md | 2 + 13 files changed, 110 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e27c4..37155d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.3.3] - 2026-08-13 +## [1.3.3] - 2026-08-14 ### Fixed @@ -410,7 +410,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - README with quick start guide - System requirements and compatibility information -[Unreleased]: https://github.com/ikelaiah/cli-fp/compare/v1.3.2...HEAD +[Unreleased]: https://github.com/ikelaiah/cli-fp/compare/v1.3.3...HEAD +[1.3.3]: https://github.com/ikelaiah/cli-fp/compare/v1.3.2...v1.3.3 [1.3.2]: https://github.com/ikelaiah/cli-fp/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/ikelaiah/cli-fp/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/ikelaiah/cli-fp/compare/v1.2.0...v1.3.0 diff --git a/README.md b/README.md index 59c17c8..34820ca 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,11 @@ PowerShell checks on Linux and Windows. CI runs the framework and generator suites on Windows and Linux. See [CONTRIBUTING.md](CONTRIBUTING.md) for coding style and pull-request guidance. +The framework runners force a complete unit rebuild into a temporary output +directory, so stale non-test `.ppu` files cannot affect the result. Test output +capture is compiled only when `CLI_FP_TESTING` is defined and is absent from +normal runtime builds. + ## Repository map | Path | Purpose | @@ -324,6 +329,8 @@ sudo apt-get install fp-compiler fp-units-fcl - [Generator guide](docs/codegen.md) โ€” use and maintain `cli-fp-gen` - [Roadmap](ROADMAP.md) โ€” planned simplification work - [Changelog](CHANGELOG.md) โ€” release history +- [v1.3.3 release notes](docs/RELEASE_NOTES_v1.3.3.md) โ€” stabilization changes + dated 2026-08-14 ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index 84ba032..4df53bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,10 +18,10 @@ paths. Examples are executable documentation and should be verified in CI. framework supports, and command execution no longer relies on a hidden unsafe downcast. -## v1.3.3 โ€” Stabilize Before Expanding (next) +## v1.3.3 โ€” Stabilize Before Expanding (implementation complete; release 2026-08-14) -This is a focused stabilization release. It should make the current framework -safer to maintain before v1.4.0 adds another public entry point. +This focused stabilization release makes the current framework safer to +maintain before v1.4.0 adds another public entry point. ### Safe repository maintenance diff --git a/docs/PULL_REQUEST_v1.3.3.md b/docs/PULL_REQUEST_v1.3.3.md index 32a3c6f..8713f15 100644 --- a/docs/PULL_REQUEST_v1.3.3.md +++ b/docs/PULL_REQUEST_v1.3.3.md @@ -2,7 +2,7 @@ **Target Release:** v1.3.3 -**Release Date:** 2026-08-13 +**Release Date:** 2026-08-14 ## Summary @@ -99,7 +99,7 @@ parameter kinds continue to require equals syntax. ## Release Readiness -- [x] Release date finalized as 2026-08-13. +- [x] Release date finalized as 2026-08-14. - [x] Version metadata updated to `1.3.3`. - [x] Changelog and release notes prepared. - [x] Pull request notes prepared. diff --git a/docs/README.md b/docs/README.md index 32adc5f..3a5871d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ # cli-fp Documentation [Project README](../README.md) ยท [Examples](../examples/) ยท -[Changelog](../CHANGELOG.md) +[Changelog](../CHANGELOG.md) ยท +[v1.3.3 release notes](RELEASE_NOTES_v1.3.3.md) Use this page to choose the shortest path to the information you need. If this is your first Free Pascal command-line project, begin with the @@ -19,6 +20,7 @@ is your first Free Pascal command-line project, begin with the | See small programs that compile | [Examples](../examples/) | | Test generated shell completion | [Completion testing guides](completion-testing/) | | Review changes between releases | [Changelog](../CHANGELOG.md) | +| Review the v1.3.3 release | [v1.3.3 release notes](RELEASE_NOTES_v1.3.3.md) | ## New to Free Pascal? @@ -74,6 +76,11 @@ fpc "-Fu.\src" .\examples\RootCommandDemo\RootCommandDemo.lpr The README, user manual, code-generator guide, API reference, and technical documentation describe the current source tree. +The v1.3.3 implementation is complete and its release is dated 2026-08-14. It +introduces hermetic test compilation, password-safe framework diagnostics, and +internal boundaries for help rendering, completion calculation, and +parameter-value semantics without changing the public application facade. + Files named `PULL_REQUEST_v*.md` and `RELEASE_NOTES_v*.md` are snapshots of a particular release. `test-output.md` records the test environment and results at the time stated in that file. Treat those files as release history rather diff --git a/docs/RELEASE_NOTES_v1.3.3.md b/docs/RELEASE_NOTES_v1.3.3.md index 708718b..84995c8 100644 --- a/docs/RELEASE_NOTES_v1.3.3.md +++ b/docs/RELEASE_NOTES_v1.3.3.md @@ -1,6 +1,6 @@ # Release Notes - cli-fp v1.3.3 -**Release Date:** 2026-08-13 +**Release Date:** 2026-08-14 ## Overview @@ -42,11 +42,13 @@ responsible for redacting sensitive values in their own output and logging. ## Smaller internal responsibilities -Help formatting is now shared by the application and base-command paths. -Completion calculation and parameter-value handling live in focused internal -units, and application dispatch is divided into named stages. Unreachable -private completion callback branches and unused temporary allocations were -removed. +Help formatting is now shared by the application and base-command paths in +`CLI.Internal.Help`. Completion calculation lives in +`CLI.Internal.Completion`, and lookup and redaction semantics live in +`CLI.Internal.ParameterValues`. Application dispatch is divided into named +global-request, command-selection, command-help, and execution stages. +Unreachable private completion callback branches and unused temporary +allocations were removed. The `TCLIApplication` facade, `ICLIApplication` contract, deprecated 1.x completion compatibility methods, and all existing command APIs remain @@ -74,4 +76,13 @@ accepted this way; unknown options remain validation errors. No migration is required. This release adds no public command API, parameter kinds, generator capabilities, completion features, or breaking changes. +## Verification + +- Framework suite: 43 tests, 0 errors, 0 failures. +- Generator unit, golden-output, compile-smoke, and operations suites pass. +- All seven canonical examples build, and cleanup smoke checks pass. +- The Lazarus package builds without adding the internal units to its generated + public `uses` list. +- Linux and Windows GitHub Actions pass. + **Full Changelog:** [v1.3.2...v1.3.3](https://github.com/ikelaiah/cli-fp/compare/v1.3.2...v1.3.3) diff --git a/docs/api-reference.md b/docs/api-reference.md index 918e3b8..703650c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -312,6 +312,12 @@ public end; ``` +The public facade is unchanged in v1.3.3. Internally, application and command +help use one renderer, completion calculation is delegated to an internal +engine, and application validation and command execution use the same +parameter-value lookup semantics. These internal units are not added to the +Lazarus package's generated public `uses` list. + #### Functions ##### `CreateCLIApplication` diff --git a/docs/completion-testing/README.md b/docs/completion-testing/README.md index 419cb5e..44b78fb 100644 --- a/docs/completion-testing/README.md +++ b/docs/completion-testing/README.md @@ -5,7 +5,7 @@ [PowerShell guide](PS_COMPLETION_GUIDE.md) **Created:** 2025-12-29 -**Last Updated:** 2026-07-28 +**Last Updated:** 2026-08-14 **Framework:** cli-fp **Coverage:** Bash and PowerShell completion documentation @@ -117,11 +117,14 @@ Checked-in generated scripts may be release snapshots. Regenerate them from the current executable before installation. ### Source Code -- `src/cli.application.pas` - Contains `__complete` implementation and script generators +- `src/cli.application.pas` - Contains the `__complete` entrypoint, a small + completion-engine wrapper, and the shell-script generators - `HandleCompletion()` - Main completion handler - - `DoComplete()` - Completion logic + - `DoComplete()` - Delegates candidate calculation to the internal engine - `OutputBashCompletionScript()` - Bash script generator - `OutputPowerShellCompletionScript()` - PowerShell script generator +- `src/cli.internal.completion.pas` - Resolves command paths and calculates + command, option, Boolean, and enum candidates ## Contributing diff --git a/docs/technical-docs.md b/docs/technical-docs.md index 6fada1d..6659778 100644 --- a/docs/technical-docs.md +++ b/docs/technical-docs.md @@ -83,6 +83,26 @@ classDiagram +Execute(): Integer #GetParameterValue(Flag: string, out Value: string): Boolean } + + class TCLIHelpRenderer { + <> + +ShowGeneral() + +ShowCommand() + +ShowComplete() + +ShowBrief() + } + + class TCLICompletionEngine { + <> + +Complete(Tokens): TStringList + } + + class CLIInternalParameterValues { + <> + +TryGetParameterValue() + +RedactParameterValue() + +RedactArgument() + } class TCommandParameter { -FShortFlag: string @@ -146,8 +166,13 @@ classDiagram TProgressIndicator <|-- TSpinner TCLIApplication --> ICommand + TCLIApplication ..> TCLIHelpRenderer + TCLIApplication ..> TCLICompletionEngine + TCLIApplication ..> CLIInternalParameterValues TBaseCommand --> ICommandParameter TBaseCommand --> ICommand + TBaseCommand ..> TCLIHelpRenderer + TBaseCommand ..> CLIInternalParameterValues ``` @@ -225,7 +250,8 @@ The `TCLIApplication` class is the central component that: `CLI.Internal.ParameterValues` owns parameter lookup semantics shared by validation and command execution. These units are internal implementation boundaries; the public `TCLIApplication` facade and `ICLIApplication` contract -are unchanged. +are unchanged. Lazarus compiles the internal units as package members but does +not add them to the generated package `uses` surface. Key methods: ```pascal @@ -240,6 +266,9 @@ private FParamStartIndex: Integer; FDebugMode: Boolean; FArguments: TStringArray; + {$IFDEF CLI_FP_TESTING} + FOutputCapture: TStrings; + {$ENDIF} public procedure RegisterCommand(const Command: ICommand); function Execute: Integer; @@ -250,6 +279,11 @@ public end; ``` +`ExecuteArguments` coordinates focused global-request, command-selection, +command-help, and execution helpers. Output capture state and +`TestExecuteAndCapture` exist only in builds compiled with +`CLI_FP_TESTING`; normal runtime units contain neither symbol. + Root-command support is introduced through an overload rather than by changing `ICLIApplication`, preserving the existing public interface contract: @@ -657,7 +691,7 @@ The completion system uses a **hidden `__complete` entrypoint** that shell scrip โ”‚ โ€ข Write directive as : on last line โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ”‚ Calls DoComplete() + โ”‚ Wrapper delegates to CompleteCLI() โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ CompleteCLI(Tokens): COMPLETION LOGIC ENGINE โ”‚ @@ -691,19 +725,17 @@ The completion system uses a **hidden `__complete` entrypoint** that shell scrip โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ–ผ โ–ผ โ–ผ โ”‚ -โ”‚ FLAG NAME FLAG VALUE POSITIONAL โ”‚ +โ”‚ FLAG NAME FLAG VALUE COMMAND POSITION โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Last token Previous token Not completing flag โ”‚ โ”‚ starts with '-' is a flag or flag value โ”‚ -โ”‚ โ”œโ”€ Complete? โ”œโ”€ Boolean? โ”œโ”€ Check custom hook โ”‚ -โ”‚ โ”‚ โ†’ --flag-name โ”‚ โ†’ true/false โ”‚ (stubbed) โ”‚ -โ”‚ โ”œโ”€ Exact match? โ”œโ”€ Enum? โ”œโ”€ argIndex = 0? โ”‚ -โ”‚ โ”‚ โ†’ Complete โ”‚ โ†’ allowed vals โ”‚ โ†’ Subcommands โ”‚ -โ”‚ value (bool/ โ”œโ”€ Custom hook? โ”‚ โ†’ Flags โ”‚ -โ”‚ enum) โ”‚ (stubbed) โ”œโ”€ argIndex > 0? โ”‚ -โ”‚ โ””โ”€ Other types? โ”‚ โ†’ Flags only โ”‚ -โ”‚ โ†’ No completion โ””โ”€ (no file completion) โ”‚ +โ”‚ โ”œโ”€ Prefix match โ”œโ”€ Boolean? โ”œโ”€ No positional entered? โ”‚ +โ”‚ โ”‚ โ†’ --flag-name โ”‚ โ†’ true/false โ”‚ โ†’ Subcommands โ”‚ +โ”‚ โ”œโ”€ Exact match? โ”œโ”€ Enum? โ”‚ โ†’ Command flags โ”‚ +โ”‚ โ”‚ โ†’ Complete โ”‚ โ†’ allowed vals โ”‚ โ†’ Help flags โ”‚ +โ”‚ value (bool/ โ””โ”€ Other types? โ””โ”€ Positional already entered?โ”‚ +โ”‚ enum) โ†’ No completion โ†’ No candidates โ”‚ โ”‚ โ”‚ โ”‚ 5. RETURN SUGGESTIONS + DIRECTIVE โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ @@ -764,13 +796,13 @@ The completion system uses a **hidden `__complete` entrypoint** that shell scrip Tokens = ["repo", "clone", "--url"] โ†“ -DoComplete(): +CompleteCLI(): 1. Tokens[0] = "repo" โ†’ Find "repo" command 2. Tokens[1] = "clone" โ†’ Find "clone" subcommand 3. Tokens[2] = "--url" โ†’ Last token is a flag - Check if "--url" is complete flag - Check parameter type - - If String: no suggestions (or custom hook) + - If String: no suggestions - If Boolean: return ["true", "false"] - If Enum: return allowed values โ†“ @@ -896,8 +928,9 @@ begin end; ``` -`CLI.Internal.Completion` performs the metadata-based Boolean and enum -completion and contains no callback lookup path. +`DoComplete` is only a facade wrapper over `CLI.Internal.Completion`. The +internal engine performs metadata-based Boolean and enum completion and +contains no callback lookup path. **Why this works:** - No function pointers stored dynamically diff --git a/docs/test-output.md b/docs/test-output.md index c4de4c5..724e7f7 100644 --- a/docs/test-output.md +++ b/docs/test-output.md @@ -1,8 +1,9 @@ # Test Output > **Historical snapshot:** This captures the 30-test suite as it ran on -> 2024-12-21. The v1.3.0 framework suite contains 38 tests; run the current -> scripts under `tests/` for release verification. +> 2024-12-21. The v1.3.3 framework suite contains 43 tests. Run the current +> hermetic scripts under `tests/` for release verification; they rebuild the +> complete unit graph in a temporary output directory. ## 2024-12-21 diff --git a/docs/user-manual.md b/docs/user-manual.md index 7c2e74d..48f44ae 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -1059,7 +1059,8 @@ echo "source \"$PWD/myapp-completion.sh\"" >> ~/.bashrc - The generated shell function forwards the current tokens to the executable's hidden `__complete` entrypoint. -- `DoComplete` resolves the command path and returns one candidate per line, +- The application delegates candidate calculation to the internal completion + engine, which resolves the command path and returns one candidate per line, followed by a completion-directive line. - The generated script currently also emits a command-tree associative array for compatibility, but dynamic completion is driven by `__complete`. diff --git a/tasks/plan.md b/tasks/plan.md index f471c89..3027326 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -1,8 +1,10 @@ # Implementation Plan: v1.3.3 Stabilization +**Status:** Completed for the 2026-08-14 release. + ## Overview -Deliver the ROADMAP.md v1.3.3 stabilization work only: safe example cleanup, +Delivered the ROADMAP.md v1.3.3 stabilization work only: safe example cleanup, behavioural coverage, hermetic builds, safe diagnostics, and behaviour- preserving internal decomposition before the next public API is added. diff --git a/tasks/todo.md b/tasks/todo.md index b62d45b..6e6563a 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,5 +1,7 @@ # v1.3.3 Task Checklist +**Status:** Completed for the 2026-08-14 release. + ## Task 1: Safe example cleanup **Acceptance criteria:**