├── src └── main │ ├── resources │ └── version.inc │ └── pascal │ ├── PasBuild.Test.Package.pas │ ├── PasBuild.Test.Clean.pas │ ├── PasBuild.Test.Init.pas │ ├── PasBuild.Test.CLI.pas │ ├── PasBuild.Test.Compile.pas │ ├── PasBuild.Test.ConfigLoader.pas │ ├── PasBuild.Test.Command.pas │ ├── PasBuild.Command.Clean.pas │ ├── PasBuild.pas │ ├── PasBuild.Command.pas │ ├── PasBuild.Test.Utils.pas │ ├── PasBuild.Command.Package.pas │ ├── PasBuild.Command.ProcessResources.pas │ ├── PasBuild.Command.ProcessTestResources.pas │ ├── PasBuild.CLI.pas │ ├── PasBuild.Command.SourcePackage.pas │ ├── PasBuild.Types.pas │ ├── PasBuild.Bootstrap.pas │ ├── PasBuild.Command.Compile.pas │ ├── PasBuild.Command.Test.pas │ ├── PasBuild.Config.pas │ ├── PasBuild.Command.Init.pas │ └── PasBuild.Utils.pas ├── .gitignore ├── LICENSE ├── project.xml ├── BOOTSTRAP.txt ├── README.adoc └── docs └── design.adoc /src/main/resources/version.inc: -------------------------------------------------------------------------------- 1 | '${project.version}' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Lazarus/FPC IDE files 2 | *.lps 3 | *.compiled 4 | fp.ini 5 | fp.cfg 6 | fp.dsk 7 | 8 | # Compiled Object files 9 | *.[oa] 10 | *.ppu 11 | *.rst 12 | 13 | # Executables 14 | *.cgi 15 | *.exe 16 | pasbuild 17 | 18 | # Build output directory (PasBuild convention) 19 | # All build artifacts go here (Maven convention) 20 | target/ 21 | 22 | # Documentation generated HTML (AsciiDoc renders) 23 | docs/*.html 24 | 25 | # Logs and backups 26 | *.log 27 | *.bak* 28 | *~ 29 | 30 | # OS-specific files 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Editor files 35 | *.swp 36 | *.swo 37 | .vscode/ 38 | .idea/ 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025 - Graeme Geldenhuys 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PasBuild 7 | 1.0.2 8 | Graeme Geldenhuys 9 | BSD-3-Clause 10 | 11 | 12 | PasBuild.pas 13 | target 14 | pasbuild 15 | 16 | 17 | 18 | UseCThreads 19 | 20 | 21 | 22 | 23 | src/main/resources 24 | true 25 | 26 | 27 | 28 | 29 | docs 30 | 31 | 32 | 33 | 34 | 35 | 36 | debug 37 | 38 | DEBUG 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | release 50 | 51 | RELEASE 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.Package.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.Package; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Config, 19 | PasBuild.Command, 20 | PasBuild.Command.Package, 21 | PasBuild.Utils; 22 | 23 | var 24 | Config: TProjectConfig; 25 | Executor: TCommandExecutor; 26 | PackageCmd: TPackageCommand; 27 | ExitCode: Integer; 28 | 29 | begin 30 | WriteLn('=== Testing Package Command ==='); 31 | WriteLn; 32 | 33 | try 34 | // Load project configuration 35 | Config := TConfigLoader.LoadProjectXML('project.xml'); 36 | try 37 | WriteLn('[Test: Package command with clean + compile dependencies]'); 38 | Executor := TCommandExecutor.Create; 39 | try 40 | PackageCmd := TPackageCommand.Create(Config, ''); 41 | try 42 | ExitCode := Executor.Execute(PackageCmd); 43 | finally 44 | PackageCmd.Free; 45 | end; 46 | finally 47 | Executor.Free; 48 | end; 49 | 50 | WriteLn; 51 | if ExitCode = 0 then 52 | WriteLn('[SUCCESS] Package command test passed') 53 | else 54 | WriteLn('[FAILURE] Package command failed with exit code: ', ExitCode); 55 | 56 | finally 57 | Config.Free; 58 | end; 59 | 60 | except 61 | on E: Exception do 62 | begin 63 | WriteLn('[ERROR] ', E.Message); 64 | Halt(1); 65 | end; 66 | end; 67 | end. 68 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.Clean.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.Clean; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Config, 19 | PasBuild.Command, 20 | PasBuild.Command.Clean, 21 | PasBuild.Utils; 22 | 23 | var 24 | Config: TProjectConfig; 25 | Executor: TCommandExecutor; 26 | CleanCmd: TCleanCommand; 27 | ExitCode: Integer; 28 | 29 | begin 30 | WriteLn('=== Testing Clean Command ==='); 31 | WriteLn; 32 | 33 | try 34 | // Load project configuration 35 | Config := TConfigLoader.LoadProjectXML('project.xml'); 36 | try 37 | // Create command executor 38 | Executor := TCommandExecutor.Create; 39 | try 40 | // Create clean command 41 | CleanCmd := TCleanCommand.Create(Config, ''); 42 | try 43 | // Execute clean 44 | ExitCode := Executor.Execute(CleanCmd); 45 | 46 | WriteLn; 47 | if ExitCode = 0 then 48 | WriteLn('[SUCCESS] Clean command executed successfully') 49 | else 50 | WriteLn('[FAILURE] Clean command failed with exit code: ', ExitCode); 51 | 52 | finally 53 | CleanCmd.Free; 54 | end; 55 | finally 56 | Executor.Free; 57 | end; 58 | finally 59 | Config.Free; 60 | end; 61 | 62 | except 63 | on E: Exception do 64 | begin 65 | WriteLn('[ERROR] ', E.Message); 66 | Halt(1); 67 | end; 68 | end; 69 | end. 70 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.Init.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.Init; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Config, 19 | PasBuild.Command, 20 | PasBuild.Command.Init, 21 | PasBuild.Utils; 22 | 23 | var 24 | Config: TProjectConfig; 25 | Executor: TCommandExecutor; 26 | InitCmd: TInitCommand; 27 | ExitCode: Integer; 28 | 29 | begin 30 | WriteLn('=== Testing Init Command ==='); 31 | WriteLn; 32 | WriteLn('NOTE: This test requires manual input'); 33 | WriteLn(' Press ENTER to accept defaults'); 34 | WriteLn; 35 | 36 | try 37 | // Create a minimal config (init doesn't use it, but Command base class needs it) 38 | Config := TProjectConfig.Create; 39 | try 40 | Executor := TCommandExecutor.Create; 41 | try 42 | InitCmd := TInitCommand.Create(Config, ''); 43 | try 44 | ExitCode := Executor.Execute(InitCmd); 45 | finally 46 | InitCmd.Free; 47 | end; 48 | finally 49 | Executor.Free; 50 | end; 51 | 52 | WriteLn; 53 | if ExitCode = 0 then 54 | WriteLn('[SUCCESS] Init command test passed') 55 | else 56 | WriteLn('[FAILURE] Init command failed with exit code: ', ExitCode); 57 | 58 | finally 59 | Config.Free; 60 | end; 61 | 62 | except 63 | on E: Exception do 64 | begin 65 | WriteLn('[ERROR] ', E.Message); 66 | Halt(1); 67 | end; 68 | end; 69 | end. 70 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.CLI.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.CLI; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.CLI; 18 | 19 | procedure TestParseArguments; 20 | var 21 | Args: TCommandLineArgs; 22 | ProfileId: string; 23 | begin 24 | WriteLn('=== Testing CLI Argument Parser ==='); 25 | WriteLn; 26 | 27 | Args := TArgumentParser.ParseArguments; 28 | 29 | WriteLn('[Parsed Arguments]'); 30 | 31 | if Args.ShowHelp then 32 | begin 33 | WriteLn(' Action: Show Help'); 34 | if Args.ErrorMessage <> '' then 35 | WriteLn(' Error: ', Args.ErrorMessage); 36 | WriteLn; 37 | TArgumentParser.ShowHelp; 38 | Args.ProfileIds.Free; 39 | Exit; 40 | end; 41 | 42 | if Args.ShowVersion then 43 | begin 44 | WriteLn(' Action: Show Version'); 45 | WriteLn; 46 | TArgumentParser.ShowVersion; 47 | Args.ProfileIds.Free; 48 | Exit; 49 | end; 50 | 51 | WriteLn(' Goal: ', Ord(Args.Goal)); 52 | Write(' Profiles: '); 53 | if Args.ProfileIds.Count = 0 then 54 | WriteLn('(none)') 55 | else 56 | begin 57 | for ProfileId in Args.ProfileIds do 58 | Write(ProfileId, ' '); 59 | WriteLn; 60 | end; 61 | 62 | if Args.ErrorMessage <> '' then 63 | begin 64 | WriteLn; 65 | WriteLn('[ERROR] ', Args.ErrorMessage); 66 | ExitCode := 1; 67 | end 68 | else 69 | begin 70 | WriteLn; 71 | WriteLn('[SUCCESS] Arguments parsed correctly'); 72 | end; 73 | 74 | Args.ProfileIds.Free; 75 | end; 76 | 77 | begin 78 | TestParseArguments; 79 | end. 80 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.Compile.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.Compile; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Config, 19 | PasBuild.Command, 20 | PasBuild.Command.Compile, 21 | PasBuild.Utils; 22 | 23 | var 24 | Config: TProjectConfig; 25 | Executor: TCommandExecutor; 26 | CompileCmd: TCompileCommand; 27 | ExitCode: Integer; 28 | 29 | begin 30 | WriteLn('=== Testing Compile Command ==='); 31 | WriteLn; 32 | 33 | try 34 | // Load project configuration 35 | Config := TConfigLoader.LoadProjectXML('project.xml'); 36 | try 37 | WriteLn('[Test 1: Compile without profile]'); 38 | Executor := TCommandExecutor.Create; 39 | try 40 | CompileCmd := TCompileCommand.Create(Config, ''); 41 | try 42 | ExitCode := Executor.Execute(CompileCmd); 43 | finally 44 | CompileCmd.Free; 45 | end; 46 | finally 47 | Executor.Free; 48 | end; 49 | 50 | WriteLn; 51 | WriteLn('========================================'); 52 | WriteLn; 53 | 54 | WriteLn('[Test 2: Compile with debug profile]'); 55 | Executor := TCommandExecutor.Create; 56 | try 57 | CompileCmd := TCompileCommand.Create(Config, 'debug'); 58 | try 59 | ExitCode := Executor.Execute(CompileCmd); 60 | finally 61 | CompileCmd.Free; 62 | end; 63 | finally 64 | Executor.Free; 65 | end; 66 | 67 | WriteLn; 68 | if ExitCode = 0 then 69 | WriteLn('[SUCCESS] Compile command tests passed') 70 | else 71 | WriteLn('[FAILURE] Compile command failed with exit code: ', ExitCode); 72 | 73 | finally 74 | Config.Free; 75 | end; 76 | 77 | except 78 | on E: Exception do 79 | begin 80 | WriteLn('[ERROR] ', E.Message); 81 | Halt(1); 82 | end; 83 | end; 84 | end. 85 | -------------------------------------------------------------------------------- /BOOTSTRAP.txt: -------------------------------------------------------------------------------- 1 | PasBuild Bootstrap Instructions 2 | ================================ 3 | 4 | PasBuild is a self-hosting build tool, meaning it can build itself once 5 | bootstrapped. This file contains instructions for the initial compilation 6 | before PasBuild can be used to build itself. 7 | 8 | Prerequisites 9 | ------------- 10 | 11 | - Free Pascal Compiler (FPC) 3.2.2 or later installed and available in PATH 12 | - Verify installation: fpc -iV 13 | 14 | 15 | Bootstrap Steps 16 | --------------- 17 | 18 | 1. Create the output directories: 19 | 20 | Linux/macOS/FreeBSD/Unix: 21 | mkdir -p target/units 22 | 23 | Windows: 24 | mkdir target\units 25 | 26 | 27 | 2. Process resources (copy and filter version.inc): 28 | 29 | Linux/macOS/FreeBSD/Unix: 30 | mkdir -p target 31 | sed 's/\${project.version}/1.0.0/g' src/main/resources/version.inc > target/version.inc 32 | 33 | Windows (PowerShell): 34 | mkdir target 35 | (Get-Content src\main\resources\version.inc) -replace '\$\{project\.version\}', '1.0.0' | Set-Content target\version.inc 36 | 37 | Windows (Command Prompt - manual): 38 | mkdir target 39 | echo '1.0.0' > target\version.inc 40 | 41 | 42 | 3. Compile PasBuild using FPC directly: 43 | 44 | Linux/macOS/FreeBSD/Unix: 45 | fpc -Mobjfpc -O1 -FEtarget -FUtarget/units -Fitarget -Fusrc/main/pascal src/main/pascal/PasBuild.pas 46 | 47 | Windows: 48 | fpc -Mobjfpc -O1 -FEtarget -FUtarget\units -Fitarget -Fusrc\main\pascal src\main\pascal\PasBuild.pas 49 | 50 | 51 | 4. Verify the build: 52 | 53 | Linux/macOS/FreeBSD/Unix: 54 | ./target/PasBuild --version 55 | 56 | Windows: 57 | target\PasBuild.exe --version 58 | 59 | 60 | 5. Once bootstrapped, you can use PasBuild to build itself: 61 | 62 | Linux/macOS/FreeBSD/Unix: 63 | ./target/PasBuild compile 64 | 65 | Windows: 66 | target\PasBuild.exe compile 67 | 68 | 69 | Troubleshooting 70 | --------------- 71 | 72 | - If you get "fpc: command not found", ensure FPC is installed and in your PATH 73 | - If compilation fails, check that you're in the project root directory 74 | - The output executable will be in the target/ directory 75 | - Unit files (.ppu, .o) will be in target/units/ 76 | 77 | 78 | Clean Build 79 | ----------- 80 | 81 | To clean and rebuild from scratch: 82 | 83 | Linux/macOS/FreeBSD/Unix: 84 | rm -rf target 85 | mkdir -p target/units 86 | sed 's/\${project.version}/1.0.0/g' src/main/resources/version.inc > target/version.inc 87 | fpc -Mobjfpc -O1 -FEtarget -FUtarget/units -Fitarget -Fusrc/main/pascal src/main/pascal/PasBuild.pas 88 | 89 | Windows: 90 | rmdir /s /q target 91 | mkdir target\units 92 | echo '1.0.0' > target\version.inc 93 | fpc -Mobjfpc -O1 -FEtarget -FUtarget\units -Fitarget -Fusrc\main\pascal src\main\pascal\PasBuild.pas 94 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.ConfigLoader.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program TestConfigLoader; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Config; 19 | 20 | var 21 | Config: TProjectConfig; 22 | Profile: TProfile; 23 | Define, Option: string; 24 | 25 | begin 26 | WriteLn('Testing ConfigLoader with project.xml...'); 27 | WriteLn; 28 | 29 | try 30 | // Load the configuration 31 | Config := TConfigLoader.LoadProjectXML('project.xml'); 32 | try 33 | WriteLn('[Metadata]'); 34 | WriteLn(' Name: ', Config.Name); 35 | WriteLn(' Version: ', Config.Version); 36 | WriteLn(' Author: ', Config.Author); 37 | WriteLn(' License: ', Config.License); 38 | WriteLn; 39 | 40 | WriteLn('[Build Configuration]'); 41 | WriteLn(' Main Source: ', Config.BuildConfig.MainSource); 42 | WriteLn(' Output Directory: ', Config.BuildConfig.OutputDirectory); 43 | WriteLn(' Executable Name: ', Config.BuildConfig.ExecutableName); 44 | WriteLn; 45 | 46 | if Config.BuildConfig.Defines.Count > 0 then 47 | begin 48 | WriteLn(' Global Defines:'); 49 | for Define in Config.BuildConfig.Defines do 50 | WriteLn(' -d', Define); 51 | WriteLn; 52 | end; 53 | 54 | if Config.Profiles.Count > 0 then 55 | begin 56 | WriteLn('[Profiles]'); 57 | for Profile in Config.Profiles do 58 | begin 59 | WriteLn(' Profile: ', Profile.Id); 60 | if Profile.Defines.Count > 0 then 61 | begin 62 | WriteLn(' Defines:'); 63 | for Define in Profile.Defines do 64 | WriteLn(' -d', Define); 65 | end; 66 | if Profile.CompilerOptions.Count > 0 then 67 | begin 68 | WriteLn(' Compiler Options:'); 69 | for Option in Profile.CompilerOptions do 70 | WriteLn(' ', Option); 71 | end; 72 | WriteLn; 73 | end; 74 | end; 75 | 76 | // Validate the configuration 77 | WriteLn('[Validation]'); 78 | if TConfigLoader.ValidateConfig(Config) then 79 | WriteLn(' ✓ Configuration is valid') 80 | else 81 | WriteLn(' ✗ Configuration is invalid'); 82 | 83 | finally 84 | Config.Free; 85 | end; 86 | 87 | except 88 | on E: EProjectConfigError do 89 | begin 90 | WriteLn('[ERROR] ', E.Message); 91 | ExitCode := 1; 92 | end; 93 | on E: Exception do 94 | begin 95 | WriteLn('[ERROR] Unexpected error: ', E.Message); 96 | ExitCode := 1; 97 | end; 98 | end; 99 | end. 100 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.Command.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.Command; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Config, 19 | PasBuild.Command, 20 | PasBuild.Command.Clean, 21 | PasBuild.Utils; 22 | 23 | type 24 | { Mock command for testing dependency resolution } 25 | TMockCommand = class(TBuildCommand) 26 | private 27 | FCommandName: string; 28 | protected 29 | function GetName: string; override; 30 | public 31 | constructor Create(AConfig: TProjectConfig; AProfileIds: TStringList; const AName: string); 32 | function Execute: Integer; override; 33 | end; 34 | 35 | constructor TMockCommand.Create(AConfig: TProjectConfig; AProfileIds: TStringList; const AName: string); 36 | begin 37 | inherited Create(AConfig, AProfileIds); 38 | FCommandName := AName; 39 | end; 40 | 41 | function TMockCommand.GetName: string; 42 | begin 43 | Result := FCommandName; 44 | end; 45 | 46 | function TMockCommand.Execute: Integer; 47 | begin 48 | TUtils.LogInfo('Executing mock command: ' + FCommandName); 49 | Result := 0; 50 | end; 51 | 52 | var 53 | Config: TProjectConfig; 54 | Executor: TCommandExecutor; 55 | Clean1, Clean2: TCleanCommand; 56 | Mock1: TMockCommand; 57 | ExitCode: Integer; 58 | 59 | begin 60 | WriteLn('=== Testing Command Pattern ==='); 61 | WriteLn; 62 | 63 | try 64 | Config := TConfigLoader.LoadProjectXML('project.xml'); 65 | try 66 | Executor := TCommandExecutor.Create; 67 | try 68 | WriteLn('[Test 1: Execute same command twice - should skip second time]'); 69 | Clean1 := TCleanCommand.Create(Config, nil); 70 | Clean2 := TCleanCommand.Create(Config, nil); 71 | try 72 | ExitCode := Executor.Execute(Clean1); 73 | WriteLn; 74 | ExitCode := Executor.Execute(Clean2); // Should be skipped 75 | WriteLn; 76 | finally 77 | Clean1.Free; 78 | Clean2.Free; 79 | end; 80 | 81 | WriteLn('[Test 2: Execute mock command]'); 82 | Mock1 := TMockCommand.Create(Config, nil, 'test-mock'); 83 | try 84 | ExitCode := Executor.Execute(Mock1); 85 | finally 86 | Mock1.Free; 87 | end; 88 | 89 | WriteLn; 90 | if ExitCode = 0 then 91 | WriteLn('[SUCCESS] Command pattern tests passed') 92 | else 93 | WriteLn('[FAILURE] Command pattern tests failed'); 94 | 95 | finally 96 | Executor.Free; 97 | end; 98 | finally 99 | Config.Free; 100 | end; 101 | 102 | except 103 | on E: Exception do 104 | begin 105 | WriteLn('[ERROR] ', E.Message); 106 | Halt(1); 107 | end; 108 | end; 109 | end. 110 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.Clean.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.Clean; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, StrUtils, 19 | PasBuild.Types, 20 | PasBuild.Command, 21 | PasBuild.Utils; 22 | 23 | type 24 | { Clean command - deletes build artifacts } 25 | TCleanCommand = class(TBuildCommand) 26 | protected 27 | function GetName: string; override; 28 | public 29 | function Execute: Integer; override; 30 | end; 31 | 32 | implementation 33 | 34 | { Helper function to recursively delete directory } 35 | function DeleteDirectory(const DirName: string): Boolean; 36 | var 37 | SearchRec: TSearchRec; 38 | FilePath: string; 39 | begin 40 | Result := True; 41 | 42 | if FindFirst(IncludeTrailingPathDelimiter(DirName) + '*', faAnyFile, SearchRec) = 0 then 43 | begin 44 | try 45 | repeat 46 | if (SearchRec.Name <> '.') and (SearchRec.Name <> '..') then 47 | begin 48 | FilePath := IncludeTrailingPathDelimiter(DirName) + SearchRec.Name; 49 | 50 | if (SearchRec.Attr and faDirectory) = faDirectory then 51 | begin 52 | // Recursively delete subdirectory 53 | if not DeleteDirectory(FilePath) then 54 | Result := False; 55 | end 56 | else 57 | begin 58 | // Delete file 59 | if not DeleteFile(FilePath) then 60 | Result := False; 61 | end; 62 | end; 63 | until FindNext(SearchRec) <> 0; 64 | finally 65 | FindClose(SearchRec); 66 | end; 67 | end; 68 | 69 | // Remove the directory itself 70 | if Result then 71 | Result := RemoveDir(DirName); 72 | end; 73 | 74 | { TCleanCommand } 75 | 76 | function TCleanCommand.GetName: string; 77 | begin 78 | Result := 'clean'; 79 | end; 80 | 81 | function TCleanCommand.Execute: Integer; 82 | var 83 | OutputDir: string; 84 | ProjectRoot: string; 85 | begin 86 | Result := 0; 87 | 88 | TUtils.LogInfo('Cleaning project...'); 89 | 90 | // Get output directory from config 91 | OutputDir := Config.BuildConfig.OutputDirectory; 92 | 93 | // Normalize path for cross-platform compatibility 94 | OutputDir := TUtils.NormalizePath(OutputDir); 95 | 96 | // Get project root (current directory) 97 | ProjectRoot := GetCurrentDir; 98 | 99 | // Build full path to output directory (if relative) 100 | // Check if path is relative (doesn't start with / or drive letter) 101 | if not ((Length(OutputDir) > 0) and 102 | ((OutputDir[1] = DirectorySeparator) or 103 | ((Length(OutputDir) > 1) and (OutputDir[2] = ':')))) then 104 | OutputDir := IncludeTrailingPathDelimiter(ProjectRoot) + OutputDir; 105 | 106 | // Safety check: only delete if it's the configured output directory 107 | // and it's under the project root 108 | if not AnsiStartsText(ProjectRoot, OutputDir) then 109 | begin 110 | TUtils.LogError('Safety check failed: Output directory is outside project root'); 111 | TUtils.LogError('Project root: ' + ProjectRoot); 112 | TUtils.LogError('Output directory: ' + OutputDir); 113 | Result := 1; 114 | Exit; 115 | end; 116 | 117 | // Additional safety: don't delete project root itself 118 | if SameText(OutputDir, ProjectRoot) or 119 | SameText(OutputDir, IncludeTrailingPathDelimiter(ProjectRoot)) then 120 | begin 121 | TUtils.LogError('Safety check failed: Cannot delete project root directory'); 122 | Result := 1; 123 | Exit; 124 | end; 125 | 126 | // Check if directory exists 127 | if not DirectoryExists(OutputDir) then 128 | begin 129 | TUtils.LogInfo('Output directory does not exist: ' + OutputDir); 130 | TUtils.LogInfo('Nothing to clean'); 131 | Exit; 132 | end; 133 | 134 | TUtils.LogInfo('Deleting: ' + OutputDir); 135 | 136 | // Delete the directory 137 | try 138 | if not DeleteDirectory(OutputDir) then 139 | begin 140 | TUtils.LogError('Failed to delete directory: ' + OutputDir); 141 | Result := 1; 142 | Exit; 143 | end; 144 | except 145 | on E: Exception do 146 | begin 147 | TUtils.LogError('Error deleting directory: ' + E.Message); 148 | Result := 1; 149 | Exit; 150 | end; 151 | end; 152 | 153 | TUtils.LogInfo('Clean complete'); 154 | end; 155 | 156 | end. 157 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | {$IFDEF UNIX} 17 | cthreads, 18 | {$ENDIF} 19 | SysUtils, 20 | PasBuild.Types, 21 | PasBuild.Config, 22 | PasBuild.CLI, 23 | PasBuild.Command, 24 | PasBuild.Command.Clean, 25 | PasBuild.Command.ProcessResources, 26 | PasBuild.Command.Compile, 27 | PasBuild.Command.ProcessTestResources, 28 | PasBuild.Command.Test, 29 | PasBuild.Command.Package, 30 | PasBuild.Command.SourcePackage, 31 | PasBuild.Command.Init, 32 | PasBuild.Utils; 33 | 34 | var 35 | Args: TCommandLineArgs; 36 | Config: TProjectConfig; 37 | Executor: TCommandExecutor; 38 | Command: TBuildCommand; 39 | 40 | begin 41 | WriteLn('[INFO] PasBuild ', PASBUILD_VERSION); 42 | WriteLn('[INFO] Copyright (c) 2025 by Graeme Geldenhuys'); 43 | WriteLn; 44 | 45 | // Parse command line arguments 46 | Args := TArgumentParser.ParseArguments; 47 | 48 | // Handle help 49 | if Args.ShowHelp then 50 | begin 51 | if Args.ErrorMessage <> '' then 52 | begin 53 | TUtils.LogError(Args.ErrorMessage); 54 | WriteLn; 55 | end; 56 | TArgumentParser.ShowHelp; 57 | if Args.ErrorMessage <> '' then 58 | ExitCode := 1 59 | else 60 | ExitCode := 0; 61 | Exit; 62 | end; 63 | 64 | // Handle version 65 | if Args.ShowVersion then 66 | begin 67 | TArgumentParser.ShowVersion; 68 | ExitCode := 0; 69 | Exit; 70 | end; 71 | 72 | // Load project configuration (skip for init goal) 73 | if Args.Goal = bgInit then 74 | begin 75 | // Init goal creates project.xml, so create empty config 76 | Config := TProjectConfig.Create; 77 | end 78 | else 79 | begin 80 | try 81 | Config := TConfigLoader.LoadProjectXML(Args.ProjectFile); 82 | except 83 | on E: EProjectConfigError do 84 | begin 85 | TUtils.LogError(E.Message); 86 | ExitCode := 1; 87 | Exit; 88 | end; 89 | on E: Exception do 90 | begin 91 | TUtils.LogError('Failed to load ' + Args.ProjectFile + ': ' + E.Message); 92 | ExitCode := 1; 93 | Exit; 94 | end; 95 | end; 96 | end; 97 | 98 | try 99 | // Validate configuration (skip for init goal) 100 | if Args.Goal <> bgInit then 101 | begin 102 | try 103 | TConfigLoader.ValidateConfig(Config); 104 | except 105 | on E: EProjectConfigError do 106 | begin 107 | TUtils.LogError(E.Message); 108 | ExitCode := 1; 109 | Exit; 110 | end; 111 | end; 112 | end; 113 | 114 | // Create command executor 115 | Executor := TCommandExecutor.Create; 116 | try 117 | Command := nil; 118 | 119 | // Create appropriate command based on goal 120 | case Args.Goal of 121 | bgClean: 122 | Command := TCleanCommand.Create(Config, Args.ProfileIds); 123 | 124 | bgProcessResources: 125 | Command := TProcessResourcesCommand.Create(Config, Config.ResourcesConfig, Config.BuildConfig.OutputDirectory); 126 | 127 | bgCompile: 128 | Command := TCompileCommand.Create(Config, Args.ProfileIds); 129 | 130 | bgProcessTestResources: 131 | Command := TProcessTestResourcesCommand.Create(Config, Config.TestResourcesConfig, Config.BuildConfig.OutputDirectory); 132 | 133 | bgTestCompile: 134 | Command := TTestCompileCommand.Create(Config, Args.ProfileIds); 135 | 136 | bgTest: 137 | Command := TTestCommand.Create(Config, Args.ProfileIds); 138 | 139 | bgPackage: 140 | Command := TPackageCommand.Create(Config, Args.ProfileIds); 141 | 142 | bgSourcePackage: 143 | Command := TSourcePackageCommand.Create(Config, Args.ProfileIds); 144 | 145 | bgInit: 146 | Command := TInitCommand.Create(Config, Args.ProfileIds); 147 | 148 | else 149 | begin 150 | TUtils.LogError('Unknown goal'); 151 | ExitCode := 1; 152 | Exit; 153 | end; 154 | end; 155 | 156 | // Execute command 157 | if Assigned(Command) then 158 | begin 159 | try 160 | Command.Verbose := Args.Verbose; 161 | ExitCode := Executor.Execute(Command); 162 | finally 163 | Command.Free; 164 | end; 165 | end; 166 | 167 | finally 168 | Executor.Free; 169 | end; 170 | 171 | finally 172 | Args.ProfileIds.Free; 173 | Config.Free; 174 | end; 175 | end. 176 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, fgl, 19 | PasBuild.Types; 20 | 21 | type 22 | { Forward declaration } 23 | TBuildCommand = class; 24 | 25 | { Command list type } 26 | TBuildCommandList = specialize TFPGObjectList; 27 | 28 | { Abstract base command - Command Pattern } 29 | TBuildCommand = class 30 | protected 31 | FConfig: TProjectConfig; 32 | FProfileIds: TStringList; 33 | FVerbose: Boolean; 34 | function GetName: string; virtual; abstract; 35 | public 36 | constructor Create(AConfig: TProjectConfig; AProfileIds: TStringList); virtual; 37 | destructor Destroy; override; 38 | 39 | { Execute the command } 40 | function Execute: Integer; virtual; abstract; 41 | 42 | { Get command dependencies (goals that must run first) } 43 | function GetDependencies: TBuildCommandList; virtual; 44 | 45 | property Name: string read GetName; 46 | property Config: TProjectConfig read FConfig; 47 | property ProfileIds: TStringList read FProfileIds; 48 | property Verbose: Boolean read FVerbose write FVerbose; 49 | end; 50 | 51 | { Command executor - executes commands with dependency resolution } 52 | TCommandExecutor = class 53 | private 54 | FExecutedCommands: TStringList; 55 | function HasExecuted(const ACommandName: string): Boolean; 56 | procedure MarkExecuted(const ACommandName: string); 57 | public 58 | constructor Create; 59 | destructor Destroy; override; 60 | 61 | { Execute a command and all its dependencies } 62 | function Execute(ACommand: TBuildCommand): Integer; 63 | 64 | { Execute a list of commands in order } 65 | function ExecuteAll(ACommands: TBuildCommandList): Integer; 66 | end; 67 | 68 | implementation 69 | 70 | uses 71 | PasBuild.Utils; 72 | 73 | { TBuildCommand } 74 | 75 | constructor TBuildCommand.Create(AConfig: TProjectConfig; AProfileIds: TStringList); 76 | begin 77 | inherited Create; 78 | FConfig := AConfig; 79 | FProfileIds := TStringList.Create; 80 | if Assigned(AProfileIds) then 81 | FProfileIds.AddStrings(AProfileIds); 82 | FVerbose := False; 83 | end; 84 | 85 | destructor TBuildCommand.Destroy; 86 | begin 87 | FProfileIds.Free; 88 | inherited Destroy; 89 | end; 90 | 91 | function TBuildCommand.GetDependencies: TBuildCommandList; 92 | begin 93 | // Default: no dependencies 94 | Result := TBuildCommandList.Create; 95 | Result.FreeObjects := False; // Dependencies are owned elsewhere 96 | end; 97 | 98 | { TCommandExecutor } 99 | 100 | constructor TCommandExecutor.Create; 101 | begin 102 | inherited Create; 103 | FExecutedCommands := TStringList.Create; 104 | FExecutedCommands.Sorted := True; 105 | FExecutedCommands.Duplicates := dupIgnore; 106 | end; 107 | 108 | destructor TCommandExecutor.Destroy; 109 | begin 110 | FExecutedCommands.Free; 111 | inherited Destroy; 112 | end; 113 | 114 | function TCommandExecutor.HasExecuted(const ACommandName: string): Boolean; 115 | begin 116 | Result := FExecutedCommands.IndexOf(ACommandName) >= 0; 117 | end; 118 | 119 | procedure TCommandExecutor.MarkExecuted(const ACommandName: string); 120 | begin 121 | FExecutedCommands.Add(ACommandName); 122 | end; 123 | 124 | function TCommandExecutor.Execute(ACommand: TBuildCommand): Integer; 125 | var 126 | Dependencies: TBuildCommandList; 127 | Dependency: TBuildCommand; 128 | I: Integer; 129 | begin 130 | Result := 0; 131 | 132 | // Skip if already executed 133 | if HasExecuted(ACommand.Name) then 134 | begin 135 | TUtils.LogInfo('Skipping ' + ACommand.Name + ' (already executed)'); 136 | Exit; 137 | end; 138 | 139 | // Execute dependencies first 140 | Dependencies := ACommand.GetDependencies; 141 | try 142 | for Dependency in Dependencies do 143 | begin 144 | Result := Execute(Dependency); 145 | if Result <> 0 then 146 | begin 147 | TUtils.LogError('Dependency failed: ' + Dependency.Name); 148 | Exit; 149 | end; 150 | end; 151 | 152 | // Free dependency commands (they're not owned by the list) 153 | for I := 0 to Dependencies.Count - 1 do 154 | Dependencies[I].Free; 155 | finally 156 | Dependencies.Free; 157 | end; 158 | 159 | // Execute the command itself 160 | TUtils.LogInfo('Executing goal: ' + ACommand.Name); 161 | Result := ACommand.Execute; 162 | 163 | if Result = 0 then 164 | MarkExecuted(ACommand.Name) 165 | else 166 | TUtils.LogError('Goal failed: ' + ACommand.Name); 167 | end; 168 | 169 | function TCommandExecutor.ExecuteAll(ACommands: TBuildCommandList): Integer; 170 | var 171 | Command: TBuildCommand; 172 | begin 173 | Result := 0; 174 | 175 | for Command in ACommands do 176 | begin 177 | Result := Execute(Command); 178 | if Result <> 0 then 179 | Exit; 180 | end; 181 | end; 182 | 183 | end. 184 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Test.Utils.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | program PasBuild.Test.Utils; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | uses 16 | Classes, SysUtils, 17 | PasBuild.Types, 18 | PasBuild.Utils; 19 | 20 | var 21 | UnitPaths, ActiveDefines: TStringList; 22 | ConditionalPaths: TConditionalPathList; 23 | Path: string; 24 | FPCVersion: string; 25 | 26 | begin 27 | WriteLn('=== Testing PasBuild.Utils ==='); 28 | WriteLn; 29 | 30 | // Test path normalization 31 | WriteLn('[Path Normalization]'); 32 | WriteLn(' Input: src/main/pascal'); 33 | WriteLn(' Output: ', TUtils.NormalizePath('src/main/pascal')); 34 | WriteLn(' Input: src/test/resources'); 35 | WriteLn(' Output: ', TUtils.NormalizePath('src/test/resources')); 36 | WriteLn; 37 | 38 | // Test directory layout verification 39 | WriteLn('[Directory Layout Verification]'); 40 | if TUtils.VerifyDirectoryLayout('.') then 41 | WriteLn(' ✓ Standard layout found') 42 | else 43 | WriteLn(' ✗ Standard layout NOT found'); 44 | WriteLn; 45 | 46 | // Test unit path scanning 47 | WriteLn('[Unit Path Scanning]'); 48 | UnitPaths := TUtils.ScanForUnitPaths('src/main/pascal'); 49 | try 50 | if UnitPaths.Count > 0 then 51 | begin 52 | WriteLn(' Found ', UnitPaths.Count, ' subdirectories:'); 53 | for Path in UnitPaths do 54 | WriteLn(' ', Path); 55 | end 56 | else 57 | WriteLn(' No subdirectories found'); 58 | finally 59 | UnitPaths.Free; 60 | end; 61 | WriteLn; 62 | 63 | // Test FPC detection 64 | WriteLn('[FPC Detection]'); 65 | if TUtils.IsFPCAvailable then 66 | begin 67 | FPCVersion := TUtils.DetectFPCVersion; 68 | WriteLn(' ✓ FPC found: version ', FPCVersion); 69 | end 70 | else 71 | WriteLn(' ✗ FPC not found in PATH'); 72 | WriteLn; 73 | 74 | // Test platform utilities 75 | WriteLn('[Platform Utilities]'); 76 | WriteLn(' Executable suffix: "', TUtils.GetPlatformExecutableSuffix, '"'); 77 | WriteLn(' Directory separator: "', DirectorySeparator, '"'); 78 | WriteLn; 79 | 80 | // Test logging 81 | WriteLn('[Logging Tests]'); 82 | TUtils.LogInfo('This is an info message'); 83 | TUtils.LogWarning('This is a warning message'); 84 | TUtils.LogError('This is an error message'); 85 | WriteLn; 86 | 87 | // Test conditional path filtering - IsConditionMet 88 | WriteLn('[Conditional Path - IsConditionMet]'); 89 | ActiveDefines := TStringList.Create; 90 | try 91 | ActiveDefines.Add('DEBUG'); 92 | ActiveDefines.Add('MY_CUSTOM_DEFINE'); 93 | 94 | // Test built-in platform defines 95 | {$IFDEF UNIX} 96 | WriteLn(' ✓ UNIX condition = ', TUtils.IsConditionMet('UNIX', ActiveDefines)); 97 | {$ELSE} 98 | WriteLn(' ✗ UNIX condition = ', TUtils.IsConditionMet('UNIX', ActiveDefines)); 99 | {$ENDIF} 100 | 101 | {$IFDEF WINDOWS} 102 | WriteLn(' ✓ WINDOWS condition = ', TUtils.IsConditionMet('WINDOWS', ActiveDefines)); 103 | {$ELSE} 104 | WriteLn(' ✗ WINDOWS condition = ', TUtils.IsConditionMet('WINDOWS', ActiveDefines)); 105 | {$ENDIF} 106 | 107 | // Test custom defines 108 | WriteLn(' DEBUG condition = ', TUtils.IsConditionMet('DEBUG', ActiveDefines)); 109 | WriteLn(' MY_CUSTOM_DEFINE condition = ', TUtils.IsConditionMet('MY_CUSTOM_DEFINE', ActiveDefines)); 110 | WriteLn(' NON_EXISTENT condition = ', TUtils.IsConditionMet('NON_EXISTENT', ActiveDefines)); 111 | 112 | // Test empty condition (always true) 113 | WriteLn(' Empty condition = ', TUtils.IsConditionMet('', ActiveDefines)); 114 | finally 115 | ActiveDefines.Free; 116 | end; 117 | WriteLn; 118 | 119 | // Test conditional path filtering - ScanForUnitPathsFiltered 120 | WriteLn('[Conditional Path - Filtered Unit Scanning]'); 121 | ConditionalPaths := TConditionalPathList.Create; 122 | ActiveDefines := TStringList.Create; 123 | try 124 | ConditionalPaths.FreeObjects := True; 125 | 126 | // Simulate platform-specific paths 127 | ConditionalPaths.Add(TConditionalPath.Create('platform/x11', 'UNIX')); 128 | ConditionalPaths.Add(TConditionalPath.Create('platform/gdi', 'WINDOWS')); 129 | ConditionalPaths.Add(TConditionalPath.Create('platform/cocoa', 'DARWIN')); 130 | 131 | // Test with current platform defines 132 | UnitPaths := TUtils.ScanForUnitPathsFiltered('src/main/pascal', ConditionalPaths, ActiveDefines); 133 | try 134 | WriteLn(' Filtered paths (should exclude wrong platform):'); 135 | if UnitPaths.Count > 0 then 136 | begin 137 | for Path in UnitPaths do 138 | WriteLn(' ', Path); 139 | end 140 | else 141 | WriteLn(' (no subdirectories in src/main/pascal)'); 142 | finally 143 | UnitPaths.Free; 144 | end; 145 | finally 146 | ConditionalPaths.Free; 147 | ActiveDefines.Free; 148 | end; 149 | WriteLn; 150 | 151 | WriteLn('[SUCCESS] All utility functions tested (including conditional filtering)'); 152 | end. 153 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = PasBuild 2 | :toc: 3 | :toc-placement!: 4 | :sectnums: 5 | :source-highlighter: rouge 6 | :icons: font 7 | 8 | A Maven-inspired build automation tool for Free Pascal projects. 9 | 10 | image:https://img.shields.io/badge/FPC-3.2.2+-blue.svg[FPC Version] 11 | image:https://img.shields.io/badge/license-BSD--3--Clause-green.svg[License] 12 | 13 | toc::[] 14 | 15 | == What is PasBuild? 16 | 17 | PasBuild is a command-line build tool for Free Pascal that brings Maven-like 18 | conventions and lifecycle management to Pascal development. It follows the 19 | principle of **convention over configuration** with sensible defaults, 20 | allowing you to override only what you need to change. 21 | 22 | == Features 23 | 24 | **Core Functionality:** 25 | 26 | * ✅ **pasbuild clean** - Removes build artifacts 27 | * ✅ **pasbuild compile [-p profile]** - Compiles with FPC 28 | * ✅ **pasbuild test-compile** - Compiles tests (compile → test-compile) 29 | * ✅ **pasbuild test** - Runs tests (compile → test-compile → test) 30 | * ✅ **pasbuild package** - Creates ZIP archive (clean → compile → package) 31 | * ✅ **pasbuild init** - Bootstraps new projects 32 | * ✅ **--help** / **--version** - Built-in help system 33 | 34 | **Advanced Features:** 35 | 36 | * ✅ Build profiles (debug/release with custom compiler options) 37 | * ✅ Multiple test frameworks (FPCUnit, FPTest) with auto-detection 38 | * ✅ Automatic dependency resolution between goals 39 | * ✅ Duplicate goal prevention 40 | * ✅ Fail-fast error handling 41 | * ✅ Cross-platform path handling (Maven convention) 42 | * ✅ Self-hosting (PasBuild can build itself) 43 | * ✅ Zero memory leaks (verified with -gh) 44 | 45 | **Platform Support:** 46 | 47 | * ✅ Linux 48 | * ✅ FreeBSD 49 | * ✅ macOS 50 | * ✅ Windows 51 | 52 | == Convention Over Configuration 53 | 54 | PasBuild follows Maven's philosophy: sensible defaults mean minimal configuration. 55 | 56 | **Default Directory Layout:** 57 | 58 | [source] 59 | ---- 60 | myproject/ 61 | ├── project.xml # Minimal configuration 62 | ├── src/ 63 | │ ├── main/ 64 | │ │ └── pascal/ # Your source files here 65 | │ │ └── Main.pas 66 | │ └── test/ 67 | │ └── pascal/ # Your test files here 68 | │ └── TestRunner.pas 69 | └── target/ # Build output (auto-created) 70 | ├── myproject # Compiled executable 71 | ├── TestRunner # Compiled test runner 72 | ├── units/ # Compiled units (.ppu, .o) 73 | ├── test-units/ # Compiled test units 74 | └── myproject-1.0.0.zip # Package archive 75 | ---- 76 | 77 | **Minimal Configuration:** 78 | 79 | Just define your project metadata - everything else uses sensible defaults: 80 | 81 | [source,xml] 82 | ---- 83 | 84 | 85 | MyProject 86 | 1.0.0 87 | Your Name 88 | BSD-3-Clause 89 | 90 | 91 | Main.pas 92 | myproject 93 | 94 | 95 | 96 | auto 97 | TestRunner.pas 98 | 99 | 100 | 101 | 102 | 103 | ---- 104 | 105 | **What You Get for Free:** 106 | 107 | * Automatic unit path scanning (`-Fu` flags) 108 | * Automatic include path scanning (`-Fi` flags for .inc files) 109 | * Test framework auto-detection (FPCUnit/FPTest) 110 | * Separate test compilation (Maven-style `target/test-units`) 111 | * Cross-platform executable naming (`.exe` on Windows) 112 | * Standard output directories (`target/`) 113 | * Archive creation with LICENSE and README inclusion 114 | * Profile-based builds without code changes 115 | 116 | **Only Override What You Need:** 117 | 118 | [source,xml] 119 | ---- 120 | 121 | 122 | release 123 | 124 | RELEASE 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | ---- 134 | 135 | == Quick Start 136 | 137 | See link:docs/quick-start-guide.adoc[Quick Start Guide] for detailed usage instructions. 138 | 139 | === 30-Second Start 140 | 141 | [source,bash] 142 | ---- 143 | # Install PasBuild (bootstrap from source) 144 | git clone https://github.com/graemeg/pasbuild.git 145 | cd pasbuild 146 | ./bootstrap.sh # See BOOTSTRAP.txt for manual steps 147 | 148 | # Create a new project 149 | mkdir myapp && cd myapp 150 | pasbuild init 151 | 152 | # Build and run 153 | pasbuild compile 154 | ./target/myapp 155 | 156 | # Run tests 157 | pasbuild test 158 | 159 | # Create release package 160 | pasbuild package 161 | ---- 162 | 163 | == Documentation 164 | 165 | * link:docs/quick-start-guide.adoc[Quick Start Guide] - Getting started with PasBuild 166 | * link:docs/design.adoc[Design Document] - Architecture and design rationale 167 | * link:docs/implementation-progress.adoc[Implementation Progress] - Development roadmap 168 | * link:BOOTSTRAP.txt[Bootstrap Instructions] - First-time compilation 169 | 170 | == Requirements 171 | 172 | * Free Pascal Compiler (FPC) 3.2.2 or later 173 | * FPC must be in your system PATH 174 | 175 | == License 176 | 177 | Copyright (c) 2025 Graeme Geldenhuys 178 | 179 | This project is licensed under the BSD 3-Clause License - see the link:LICENSE[LICENSE] file for details. 180 | 181 | All source files in this project are covered by this license unless otherwise noted. 182 | 183 | == Contributing 184 | 185 | Contributions are welcome! Please feel free to submit pull requests or 186 | open issues for bugs and feature requests. 187 | 188 | == Acknowledgments 189 | 190 | * Inspired by Apache Maven's convention-over-configuration philosophy 191 | * Built with Free Pascal Compiler 192 | * Uses FPC's standard library units (zipper, DOM, XMLRead) 193 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.Package.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.Package; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, Zipper, 19 | PasBuild.Types, 20 | PasBuild.Command, 21 | PasBuild.Command.Clean, 22 | PasBuild.Command.Compile, 23 | PasBuild.Utils; 24 | 25 | type 26 | { Package command - creates release archive } 27 | TPackageCommand = class(TBuildCommand) 28 | private 29 | function GetArchiveFileName: string; 30 | function FindFileWithVariants(const ABaseName: string): string; 31 | function CreateArchive(const AArchiveName: string): Integer; 32 | protected 33 | function GetName: string; override; 34 | public 35 | function Execute: Integer; override; 36 | function GetDependencies: TBuildCommandList; override; 37 | end; 38 | 39 | implementation 40 | 41 | { TPackageCommand } 42 | 43 | function TPackageCommand.GetName: string; 44 | begin 45 | Result := 'package'; 46 | end; 47 | 48 | function TPackageCommand.GetDependencies: TBuildCommandList; 49 | begin 50 | Result := TBuildCommandList.Create(False); // Don't own the objects 51 | try 52 | // Package depends on: clean → compile 53 | Result.Add(TCleanCommand.Create(Config, ProfileIds)); 54 | Result.Add(TCompileCommand.Create(Config, ProfileIds)); 55 | except 56 | Result.Free; 57 | raise; 58 | end; 59 | end; 60 | 61 | function TPackageCommand.GetArchiveFileName: string; 62 | var 63 | BaseName, OutputDir: string; 64 | begin 65 | // Archive name: target/-.zip 66 | // Following Maven convention: all build artifacts go in target/ 67 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 68 | 69 | BaseName := Config.BuildConfig.ExecutableName; 70 | if BaseName = '' then 71 | BaseName := 'app'; 72 | 73 | Result := OutputDir + DirectorySeparator + BaseName + '-' + Config.Version + '.zip'; 74 | end; 75 | 76 | function TPackageCommand.FindFileWithVariants(const ABaseName: string): string; 77 | const 78 | // Common documentation file extensions (plus no extension) 79 | FileExtensions: array[0..4] of string = ( 80 | '', // No extension (e.g., README, LICENSE) 81 | '.txt', 82 | '.md', 83 | '.adoc', 84 | '.rst' 85 | ); 86 | var 87 | Extension, FileName: string; 88 | begin 89 | Result := ''; 90 | 91 | // Try each extension variant 92 | for Extension in FileExtensions do 93 | begin 94 | FileName := ABaseName + Extension; 95 | if FileExists(FileName) then 96 | begin 97 | Result := FileName; 98 | Exit; 99 | end; 100 | end; 101 | end; 102 | 103 | function TPackageCommand.CreateArchive(const AArchiveName: string): Integer; 104 | var 105 | Zip: TZipper; 106 | OutputDir, ExeName, ExePath: string; 107 | LicenseFile, ReadmeFile, ManifestFile: string; 108 | FilesToAdd: TStringList; 109 | FileToAdd: string; 110 | begin 111 | Result := 0; 112 | 113 | // Get executable path 114 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 115 | ExeName := Config.BuildConfig.ExecutableName; 116 | if ExeName = '' then 117 | ExeName := 'app'; 118 | ExeName := ExeName + TUtils.GetPlatformExecutableSuffix; 119 | ExePath := OutputDir + DirectorySeparator + ExeName; 120 | 121 | // Check if executable exists 122 | if not FileExists(ExePath) then 123 | begin 124 | TUtils.LogError('Executable not found: ' + ExePath); 125 | Result := 1; 126 | Exit; 127 | end; 128 | 129 | // Build list of files to include 130 | FilesToAdd := TStringList.Create; 131 | try 132 | // Always include the executable 133 | FilesToAdd.Add(ExePath); 134 | 135 | // Include LICENSE if it exists (try LICENSE and COPYING) 136 | LicenseFile := FindFileWithVariants('LICENSE'); 137 | if LicenseFile = '' then 138 | LicenseFile := FindFileWithVariants('COPYING'); 139 | 140 | if LicenseFile <> '' then 141 | FilesToAdd.Add(LicenseFile) 142 | else 143 | TUtils.LogInfo('LICENSE/COPYING file not found, skipping'); 144 | 145 | // Include README if it exists 146 | ReadmeFile := FindFileWithVariants('README'); 147 | if ReadmeFile <> '' then 148 | FilesToAdd.Add(ReadmeFile) 149 | else 150 | TUtils.LogInfo('README file not found, skipping'); 151 | 152 | // Include manifest file if it exists (Windows DPI-aware configuration) 153 | // Manifest files are only used on Windows platforms 154 | if ExtractFileExt(ExeName) = '.exe' then 155 | begin 156 | ManifestFile := ExePath + '.manifest'; 157 | if FileExists(ManifestFile) then 158 | FilesToAdd.Add(ManifestFile) 159 | else 160 | TUtils.LogInfo('Manifest file not found, skipping'); 161 | end; 162 | 163 | // Create the ZIP archive 164 | Zip := TZipper.Create; 165 | try 166 | Zip.FileName := AArchiveName; 167 | 168 | // Add files with flat structure (no directories) 169 | for FileToAdd in FilesToAdd do 170 | begin 171 | TUtils.LogInfo('Adding to archive: ' + FileToAdd); 172 | Zip.Entries.AddFileEntry(FileToAdd, ExtractFileName(FileToAdd)); 173 | end; 174 | 175 | // Create the archive 176 | Zip.ZipAllFiles; 177 | 178 | TUtils.LogInfo('Created archive: ' + AArchiveName); 179 | 180 | finally 181 | Zip.Free; 182 | end; 183 | 184 | finally 185 | FilesToAdd.Free; 186 | end; 187 | end; 188 | 189 | function TPackageCommand.Execute: Integer; 190 | var 191 | ArchiveName: string; 192 | begin 193 | Result := 0; 194 | 195 | TUtils.LogInfo('Creating release package...'); 196 | 197 | // Get archive filename 198 | ArchiveName := GetArchiveFileName; 199 | 200 | // Delete existing archive if present 201 | if FileExists(ArchiveName) then 202 | begin 203 | TUtils.LogInfo('Removing existing archive: ' + ArchiveName); 204 | if not DeleteFile(ArchiveName) then 205 | begin 206 | TUtils.LogError('Failed to delete existing archive: ' + ArchiveName); 207 | Result := 1; 208 | Exit; 209 | end; 210 | end; 211 | 212 | // Create the archive 213 | Result := CreateArchive(ArchiveName); 214 | 215 | if Result = 0 then 216 | TUtils.LogInfo('Package created successfully: ' + ArchiveName) 217 | else 218 | TUtils.LogError('Package creation failed'); 219 | end; 220 | 221 | end. 222 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.ProcessResources.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.ProcessResources; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, 19 | PasBuild.Types, 20 | PasBuild.Command; 21 | 22 | type 23 | { Command to process resources (copy + optional filtering) } 24 | TProcessResourcesCommand = class(TBuildCommand) 25 | private 26 | FResourcesConfig: TResourcesConfig; 27 | FTargetDir: string; 28 | 29 | function ProcessFile(const ASourceFile, ARelativePath: string): Boolean; 30 | function ApplyFiltering(const AContent: string): string; 31 | procedure CopyResources(const ASourceDir, ATargetDir: string); 32 | public 33 | constructor Create(AConfig: TProjectConfig; AResourcesConfig: TResourcesConfig; const ATargetDir: string); 34 | 35 | function GetName: string; override; 36 | function Execute: Integer; override; 37 | end; 38 | 39 | implementation 40 | 41 | uses 42 | PasBuild.Utils, 43 | StrUtils; 44 | 45 | { TProcessResourcesCommand } 46 | 47 | constructor TProcessResourcesCommand.Create(AConfig: TProjectConfig; AResourcesConfig: TResourcesConfig; const ATargetDir: string); 48 | begin 49 | inherited Create(AConfig, nil); 50 | FResourcesConfig := AResourcesConfig; 51 | FTargetDir := ATargetDir; 52 | end; 53 | 54 | function TProcessResourcesCommand.GetName: string; 55 | begin 56 | Result := 'process-resources'; 57 | end; 58 | 59 | function TProcessResourcesCommand.ApplyFiltering(const AContent: string): string; 60 | begin 61 | Result := AContent; 62 | 63 | // Replace project variables 64 | Result := StringReplace(Result, '${project.name}', Config.Name, [rfReplaceAll]); 65 | Result := StringReplace(Result, '${project.version}', Config.Version, [rfReplaceAll]); 66 | Result := StringReplace(Result, '${project.author}', Config.Author, [rfReplaceAll]); 67 | Result := StringReplace(Result, '${project.license}', Config.License, [rfReplaceAll]); 68 | end; 69 | 70 | function TProcessResourcesCommand.ProcessFile(const ASourceFile, ARelativePath: string): Boolean; 71 | var 72 | TargetFile: string; 73 | SourceContent, FilteredContent: string; 74 | SourceStream: TFileStream; 75 | TargetStream: TFileStream; 76 | TargetDir: string; 77 | begin 78 | Result := False; 79 | 80 | // Build target file path 81 | TargetFile := IncludeTrailingPathDelimiter(FTargetDir) + ARelativePath; 82 | 83 | // Create target directory if needed 84 | TargetDir := ExtractFilePath(TargetFile); 85 | if not DirectoryExists(TargetDir) then 86 | begin 87 | if not ForceDirectories(TargetDir) then 88 | begin 89 | TUtils.LogError('Failed to create directory: ' + TargetDir); 90 | Exit; 91 | end; 92 | end; 93 | 94 | try 95 | if FResourcesConfig.Filtering then 96 | begin 97 | // Read file, apply filtering, write to target 98 | SourceStream := TFileStream.Create(ASourceFile, fmOpenRead or fmShareDenyWrite); 99 | try 100 | SetLength(SourceContent, SourceStream.Size); 101 | if SourceStream.Size > 0 then 102 | SourceStream.ReadBuffer(SourceContent[1], SourceStream.Size); 103 | finally 104 | SourceStream.Free; 105 | end; 106 | 107 | // Apply variable substitution 108 | FilteredContent := ApplyFiltering(SourceContent); 109 | 110 | // Write filtered content 111 | TargetStream := TFileStream.Create(TargetFile, fmCreate); 112 | try 113 | if Length(FilteredContent) > 0 then 114 | TargetStream.WriteBuffer(FilteredContent[1], Length(FilteredContent)); 115 | finally 116 | TargetStream.Free; 117 | end; 118 | end 119 | else 120 | begin 121 | // Simple copy without filtering 122 | SourceStream := TFileStream.Create(ASourceFile, fmOpenRead or fmShareDenyWrite); 123 | try 124 | TargetStream := TFileStream.Create(TargetFile, fmCreate); 125 | try 126 | TargetStream.CopyFrom(SourceStream, SourceStream.Size); 127 | finally 128 | TargetStream.Free; 129 | end; 130 | finally 131 | SourceStream.Free; 132 | end; 133 | end; 134 | 135 | Result := True; 136 | except 137 | on E: Exception do 138 | begin 139 | TUtils.LogError('Failed to process resource ' + ARelativePath + ': ' + E.Message); 140 | Exit; 141 | end; 142 | end; 143 | end; 144 | 145 | procedure TProcessResourcesCommand.CopyResources(const ASourceDir, ATargetDir: string); 146 | var 147 | SearchRec: TSearchRec; 148 | SourcePath, RelativePath: string; 149 | begin 150 | SourcePath := IncludeTrailingPathDelimiter(ASourceDir); 151 | 152 | // Process all files in current directory 153 | if FindFirst(SourcePath + '*', faAnyFile, SearchRec) = 0 then 154 | begin 155 | try 156 | repeat 157 | // Skip . and .. 158 | if (SearchRec.Name = '.') or (SearchRec.Name = '..') then 159 | Continue; 160 | 161 | RelativePath := SearchRec.Name; 162 | 163 | if (SearchRec.Attr and faDirectory) = faDirectory then 164 | begin 165 | // Recursively process subdirectory 166 | CopyResources(SourcePath + SearchRec.Name, ATargetDir); 167 | end 168 | else 169 | begin 170 | // Calculate relative path from source root 171 | RelativePath := Copy(SourcePath + SearchRec.Name, Length(FResourcesConfig.Directory) + 2, MaxInt); 172 | 173 | // Process file 174 | if ProcessFile(SourcePath + SearchRec.Name, RelativePath) then 175 | begin 176 | if FResourcesConfig.Filtering then 177 | TUtils.LogInfo(' Filtered: ' + RelativePath) 178 | else 179 | TUtils.LogInfo(' Copied: ' + RelativePath); 180 | end; 181 | end; 182 | until FindNext(SearchRec) <> 0; 183 | finally 184 | FindClose(SearchRec); 185 | end; 186 | end; 187 | end; 188 | 189 | function TProcessResourcesCommand.Execute: Integer; 190 | var 191 | SourceDir: string; 192 | begin 193 | Result := 1; // Assume failure 194 | 195 | SourceDir := FResourcesConfig.Directory; 196 | 197 | // Check if source directory exists 198 | if not DirectoryExists(SourceDir) then 199 | begin 200 | TUtils.LogInfo('No resources directory found (' + SourceDir + '), skipping'); 201 | Result := 0; // Not an error, just nothing to do 202 | Exit; 203 | end; 204 | 205 | TUtils.LogInfo('Processing resources from ' + SourceDir + '...'); 206 | if FResourcesConfig.Filtering then 207 | TUtils.LogInfo(' Filtering enabled'); 208 | 209 | // Copy resources 210 | CopyResources(SourceDir, FTargetDir); 211 | 212 | TUtils.LogInfo('Resources processed successfully'); 213 | Result := 0; 214 | end; 215 | 216 | end. 217 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.ProcessTestResources.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.ProcessTestResources; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, 19 | PasBuild.Types, 20 | PasBuild.Command; 21 | 22 | type 23 | { Command to process test resources (copy + optional filtering) } 24 | TProcessTestResourcesCommand = class(TBuildCommand) 25 | private 26 | FResourcesConfig: TResourcesConfig; 27 | FTargetDir: string; 28 | 29 | function ProcessFile(const ASourceFile, ARelativePath: string): Boolean; 30 | function ApplyFiltering(const AContent: string): string; 31 | procedure CopyResources(const ASourceDir, ATargetDir: string); 32 | public 33 | constructor Create(AConfig: TProjectConfig; AResourcesConfig: TResourcesConfig; const ATargetDir: string); 34 | 35 | function GetName: string; override; 36 | function Execute: Integer; override; 37 | end; 38 | 39 | implementation 40 | 41 | uses 42 | PasBuild.Utils, 43 | StrUtils; 44 | 45 | { TProcessTestResourcesCommand } 46 | 47 | constructor TProcessTestResourcesCommand.Create(AConfig: TProjectConfig; AResourcesConfig: TResourcesConfig; const ATargetDir: string); 48 | begin 49 | inherited Create(AConfig, nil); 50 | FResourcesConfig := AResourcesConfig; 51 | FTargetDir := ATargetDir; 52 | end; 53 | 54 | function TProcessTestResourcesCommand.GetName: string; 55 | begin 56 | Result := 'process-test-resources'; 57 | end; 58 | 59 | function TProcessTestResourcesCommand.ApplyFiltering(const AContent: string): string; 60 | begin 61 | Result := AContent; 62 | 63 | // Replace project variables 64 | Result := StringReplace(Result, '${project.name}', Config.Name, [rfReplaceAll]); 65 | Result := StringReplace(Result, '${project.version}', Config.Version, [rfReplaceAll]); 66 | Result := StringReplace(Result, '${project.author}', Config.Author, [rfReplaceAll]); 67 | Result := StringReplace(Result, '${project.license}', Config.License, [rfReplaceAll]); 68 | end; 69 | 70 | function TProcessTestResourcesCommand.ProcessFile(const ASourceFile, ARelativePath: string): Boolean; 71 | var 72 | TargetFile: string; 73 | SourceContent, FilteredContent: string; 74 | SourceStream: TFileStream; 75 | TargetStream: TFileStream; 76 | TargetDir: string; 77 | begin 78 | Result := False; 79 | 80 | // Build target file path 81 | TargetFile := IncludeTrailingPathDelimiter(FTargetDir) + ARelativePath; 82 | 83 | // Create target directory if needed 84 | TargetDir := ExtractFilePath(TargetFile); 85 | if not DirectoryExists(TargetDir) then 86 | begin 87 | if not ForceDirectories(TargetDir) then 88 | begin 89 | TUtils.LogError('Failed to create directory: ' + TargetDir); 90 | Exit; 91 | end; 92 | end; 93 | 94 | try 95 | if FResourcesConfig.Filtering then 96 | begin 97 | // Read file, apply filtering, write to target 98 | SourceStream := TFileStream.Create(ASourceFile, fmOpenRead or fmShareDenyWrite); 99 | try 100 | SetLength(SourceContent, SourceStream.Size); 101 | if SourceStream.Size > 0 then 102 | SourceStream.ReadBuffer(SourceContent[1], SourceStream.Size); 103 | finally 104 | SourceStream.Free; 105 | end; 106 | 107 | // Apply variable substitution 108 | FilteredContent := ApplyFiltering(SourceContent); 109 | 110 | // Write filtered content 111 | TargetStream := TFileStream.Create(TargetFile, fmCreate); 112 | try 113 | if Length(FilteredContent) > 0 then 114 | TargetStream.WriteBuffer(FilteredContent[1], Length(FilteredContent)); 115 | finally 116 | TargetStream.Free; 117 | end; 118 | end 119 | else 120 | begin 121 | // Simple copy without filtering 122 | SourceStream := TFileStream.Create(ASourceFile, fmOpenRead or fmShareDenyWrite); 123 | try 124 | TargetStream := TFileStream.Create(TargetFile, fmCreate); 125 | try 126 | TargetStream.CopyFrom(SourceStream, SourceStream.Size); 127 | finally 128 | TargetStream.Free; 129 | end; 130 | finally 131 | SourceStream.Free; 132 | end; 133 | end; 134 | 135 | Result := True; 136 | except 137 | on E: Exception do 138 | begin 139 | TUtils.LogError('Failed to process test resource ' + ARelativePath + ': ' + E.Message); 140 | Exit; 141 | end; 142 | end; 143 | end; 144 | 145 | procedure TProcessTestResourcesCommand.CopyResources(const ASourceDir, ATargetDir: string); 146 | var 147 | SearchRec: TSearchRec; 148 | SourcePath, RelativePath: string; 149 | begin 150 | SourcePath := IncludeTrailingPathDelimiter(ASourceDir); 151 | 152 | // Process all files in current directory 153 | if FindFirst(SourcePath + '*', faAnyFile, SearchRec) = 0 then 154 | begin 155 | try 156 | repeat 157 | // Skip . and .. 158 | if (SearchRec.Name = '.') or (SearchRec.Name = '..') then 159 | Continue; 160 | 161 | RelativePath := SearchRec.Name; 162 | 163 | if (SearchRec.Attr and faDirectory) = faDirectory then 164 | begin 165 | // Recursively process subdirectory 166 | CopyResources(SourcePath + SearchRec.Name, ATargetDir); 167 | end 168 | else 169 | begin 170 | // Calculate relative path from source root 171 | RelativePath := Copy(SourcePath + SearchRec.Name, Length(FResourcesConfig.Directory) + 2, MaxInt); 172 | 173 | // Process file 174 | if ProcessFile(SourcePath + SearchRec.Name, RelativePath) then 175 | begin 176 | if FResourcesConfig.Filtering then 177 | TUtils.LogInfo(' Filtered: ' + RelativePath) 178 | else 179 | TUtils.LogInfo(' Copied: ' + RelativePath); 180 | end; 181 | end; 182 | until FindNext(SearchRec) <> 0; 183 | finally 184 | FindClose(SearchRec); 185 | end; 186 | end; 187 | end; 188 | 189 | function TProcessTestResourcesCommand.Execute: Integer; 190 | var 191 | SourceDir: string; 192 | begin 193 | Result := 1; // Assume failure 194 | 195 | SourceDir := FResourcesConfig.Directory; 196 | 197 | // Check if source directory exists 198 | if not DirectoryExists(SourceDir) then 199 | begin 200 | TUtils.LogInfo('No test resources directory found (' + SourceDir + '), skipping'); 201 | Result := 0; // Not an error, just nothing to do 202 | Exit; 203 | end; 204 | 205 | TUtils.LogInfo('Processing test resources from ' + SourceDir + '...'); 206 | if FResourcesConfig.Filtering then 207 | TUtils.LogInfo(' Filtering enabled'); 208 | 209 | // Copy resources 210 | CopyResources(SourceDir, FTargetDir); 211 | 212 | TUtils.LogInfo('Test resources processed successfully'); 213 | Result := 0; 214 | end; 215 | 216 | end. 217 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.CLI.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.CLI; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils; 19 | 20 | const 21 | PASBUILD_VERSION = {$I version.inc}; 22 | 23 | type 24 | { Valid build goals } 25 | TBuildGoal = (bgUnknown, bgClean, bgProcessResources, bgCompile, bgProcessTestResources, bgTestCompile, bgTest, bgPackage, bgSourcePackage, bgInit, bgHelp, bgVersion); 26 | 27 | { Parsed command-line arguments } 28 | TCommandLineArgs = record 29 | Goal: TBuildGoal; 30 | ProfileIds: TStringList; // Changed from ProfileId to support multiple profiles 31 | ProjectFile: string; // Custom project file path (default: project.xml) 32 | ShowHelp: Boolean; 33 | ShowVersion: Boolean; 34 | Verbose: Boolean; 35 | ErrorMessage: string; 36 | end; 37 | 38 | { Command-line argument parser } 39 | TArgumentParser = class 40 | private 41 | class function GoalFromString(const AGoalStr: string): TBuildGoal; 42 | class function GoalToString(AGoal: TBuildGoal): string; 43 | public 44 | class function ParseArguments: TCommandLineArgs; 45 | class procedure ShowHelp; 46 | class procedure ShowVersion; 47 | end; 48 | 49 | implementation 50 | 51 | uses 52 | PasBuild.Utils; 53 | 54 | { TArgumentParser } 55 | 56 | class function TArgumentParser.GoalFromString(const AGoalStr: string): TBuildGoal; 57 | var 58 | GoalLower: string; 59 | begin 60 | GoalLower := LowerCase(AGoalStr); 61 | 62 | if GoalLower = 'clean' then 63 | Result := bgClean 64 | else if GoalLower = 'process-resources' then 65 | Result := bgProcessResources 66 | else if GoalLower = 'compile' then 67 | Result := bgCompile 68 | else if GoalLower = 'process-test-resources' then 69 | Result := bgProcessTestResources 70 | else if GoalLower = 'test-compile' then 71 | Result := bgTestCompile 72 | else if GoalLower = 'test' then 73 | Result := bgTest 74 | else if GoalLower = 'package' then 75 | Result := bgPackage 76 | else if GoalLower = 'source-package' then 77 | Result := bgSourcePackage 78 | else if GoalLower = 'init' then 79 | Result := bgInit 80 | else if (GoalLower = '--help') or (GoalLower = '-h') then 81 | Result := bgHelp 82 | else if GoalLower = '--version' then 83 | Result := bgVersion 84 | else 85 | Result := bgUnknown; 86 | end; 87 | 88 | class function TArgumentParser.GoalToString(AGoal: TBuildGoal): string; 89 | begin 90 | case AGoal of 91 | bgClean: Result := 'clean'; 92 | bgProcessResources: Result := 'process-resources'; 93 | bgCompile: Result := 'compile'; 94 | bgProcessTestResources: Result := 'process-test-resources'; 95 | bgTestCompile: Result := 'test-compile'; 96 | bgTest: Result := 'test'; 97 | bgPackage: Result := 'package'; 98 | bgSourcePackage: Result := 'source-package'; 99 | bgInit: Result := 'init'; 100 | bgHelp: Result := '--help'; 101 | bgVersion: Result := '--version'; 102 | else Result := 'unknown'; 103 | end; 104 | end; 105 | 106 | class function TArgumentParser.ParseArguments: TCommandLineArgs; 107 | var 108 | I: Integer; 109 | Arg: string; 110 | begin 111 | // Initialize result 112 | Result.Goal := bgUnknown; 113 | Result.ProfileIds := TStringList.Create; 114 | Result.ProfileIds.Delimiter := ','; 115 | Result.ProfileIds.StrictDelimiter := True; 116 | Result.ProjectFile := 'project.xml'; // Default 117 | Result.ShowHelp := False; 118 | Result.ShowVersion := False; 119 | Result.Verbose := False; 120 | Result.ErrorMessage := ''; 121 | 122 | // No arguments provided 123 | if ParamCount = 0 then 124 | begin 125 | Result.ErrorMessage := 'No goal specified'; 126 | Result.ShowHelp := True; 127 | Exit; 128 | end; 129 | 130 | // First parameter is always the goal 131 | Result.Goal := GoalFromString(ParamStr(1)); 132 | 133 | // Handle help and version flags 134 | if Result.Goal = bgHelp then 135 | begin 136 | Result.ShowHelp := True; 137 | Exit; 138 | end; 139 | 140 | if Result.Goal = bgVersion then 141 | begin 142 | Result.ShowVersion := True; 143 | Exit; 144 | end; 145 | 146 | // Validate goal 147 | if Result.Goal = bgUnknown then 148 | begin 149 | Result.ErrorMessage := 'Unknown goal: ' + ParamStr(1); 150 | Result.ShowHelp := True; 151 | Exit; 152 | end; 153 | 154 | // Parse remaining arguments 155 | I := 2; 156 | while I <= ParamCount do 157 | begin 158 | Arg := ParamStr(I); 159 | 160 | // Profile flag 161 | if (Arg = '-p') or (Arg = '--profile') then 162 | begin 163 | Inc(I); 164 | if I > ParamCount then 165 | begin 166 | Result.ErrorMessage := 'Option ' + Arg + ' requires a profile ID'; 167 | Exit; 168 | end; 169 | // Parse comma-separated profile IDs (e.g., -p debug,logging) 170 | Result.ProfileIds.DelimitedText := ParamStr(I); 171 | end 172 | // Verbose flag 173 | else if (Arg = '-v') or (Arg = '--verbose') then 174 | begin 175 | Result.Verbose := True; 176 | end 177 | // Project file flag 178 | else if (Arg = '-f') or (Arg = '--file') then 179 | begin 180 | Inc(I); 181 | if I > ParamCount then 182 | begin 183 | Result.ErrorMessage := 'Option ' + Arg + ' requires a file path'; 184 | Exit; 185 | end; 186 | Result.ProjectFile := ParamStr(I); 187 | end 188 | else 189 | begin 190 | Result.ErrorMessage := 'Unknown option: ' + Arg; 191 | Exit; 192 | end; 193 | 194 | Inc(I); 195 | end; 196 | end; 197 | 198 | class procedure TArgumentParser.ShowHelp; 199 | begin 200 | WriteLn('Usage: pasbuild [options]'); 201 | WriteLn; 202 | WriteLn('Goals:'); 203 | WriteLn(' clean Delete all build artifacts'); 204 | WriteLn(' process-resources Copy resources to target directory'); 205 | WriteLn(' compile Build the executable (runs: process-resources -> compile)'); 206 | WriteLn(' process-test-resources Copy test resources to target directory'); 207 | WriteLn(' test-compile Compile tests (runs: compile -> process-test-resources -> test-compile)'); 208 | WriteLn(' test Run tests (runs: compile -> process-test-resources -> test-compile -> test)'); 209 | WriteLn(' package Create release archive (runs: clean -> compile -> package)'); 210 | WriteLn(' source-package Create source archive with src/, docs, and configured files'); 211 | WriteLn(' init Create new project structure'); 212 | WriteLn; 213 | WriteLn('Options:'); 214 | WriteLn(' -p Activate build profile(s)'); 215 | WriteLn(' --profile Activate build profile (same as -p)'); 216 | WriteLn(' -f , --file Use alternate project file (default: project.xml)'); 217 | WriteLn(' -v, --verbose Show full compiler output'); 218 | WriteLn(' -h, --help Show this help message'); 219 | WriteLn(' --version Show version information'); 220 | WriteLn; 221 | WriteLn('Examples:'); 222 | WriteLn(' pasbuild compile # Build with default settings'); 223 | WriteLn(' pasbuild compile -p debug # Build with debug profile'); 224 | WriteLn(' pasbuild compile -p release # Build with release profile'); 225 | WriteLn(' pasbuild compile -p base,debug # Build with base + debug profiles'); 226 | WriteLn(' pasbuild compile -v # Build with verbose FPC output'); 227 | WriteLn(' pasbuild compile -f custom.xml # Use custom project file'); 228 | WriteLn(' pasbuild test # Run tests'); 229 | WriteLn(' pasbuild package # Create release archive'); 230 | WriteLn(' pasbuild init # Create new project'); 231 | WriteLn; 232 | end; 233 | 234 | class procedure TArgumentParser.ShowVersion; 235 | begin 236 | WriteLn('PasBuild version ', PASBUILD_VERSION); 237 | WriteLn('Build automation tool for Free Pascal projects'); 238 | WriteLn; 239 | 240 | // Try to detect FPC version 241 | WriteLn('FPC version detected (fpc -iV): ', TUtils.DetectFPCVersion()); 242 | WriteLn; 243 | end; 244 | 245 | end. 246 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.SourcePackage.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.SourcePackage; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, Zipper, 19 | PasBuild.Types, 20 | PasBuild.Command, 21 | PasBuild.Utils; 22 | 23 | type 24 | { SourcePackage command - creates source archive } 25 | TSourcePackageCommand = class(TBuildCommand) 26 | private 27 | function GetArchiveFileName: string; 28 | function FindFileWithVariants(const ABaseName: string): string; 29 | function IsPathSafe(const APath: string): Boolean; 30 | procedure AddDirectoryToZip(AZip: TZipper; const ASourceDir, AArchivePrefix: string); 31 | function CreateSourceArchive(const AArchiveName: string): Integer; 32 | protected 33 | function GetName: string; override; 34 | public 35 | function Execute: Integer; override; 36 | end; 37 | 38 | implementation 39 | 40 | { TSourcePackageCommand } 41 | 42 | function TSourcePackageCommand.GetName: string; 43 | begin 44 | Result := 'source-package'; 45 | end; 46 | 47 | function TSourcePackageCommand.GetArchiveFileName: string; 48 | var 49 | BaseName, OutputDir: string; 50 | begin 51 | // Archive name: target/--src.zip 52 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 53 | 54 | BaseName := Config.Name; 55 | if BaseName = '' then 56 | BaseName := 'project'; 57 | 58 | Result := OutputDir + DirectorySeparator + BaseName + '-' + Config.Version + '-src.zip'; 59 | end; 60 | 61 | function TSourcePackageCommand.FindFileWithVariants(const ABaseName: string): string; 62 | const 63 | FileExtensions: array[0..4] of string = ( 64 | '', // No extension 65 | '.txt', 66 | '.md', 67 | '.adoc', 68 | '.rst' 69 | ); 70 | var 71 | Extension, FileName: string; 72 | begin 73 | Result := ''; 74 | 75 | for Extension in FileExtensions do 76 | begin 77 | FileName := ABaseName + Extension; 78 | if FileExists(FileName) then 79 | begin 80 | Result := FileName; 81 | Exit; 82 | end; 83 | end; 84 | end; 85 | 86 | function TSourcePackageCommand.IsPathSafe(const APath: string): Boolean; 87 | var 88 | NormalizedPath: string; 89 | begin 90 | // Security: Only allow paths within project root (no .. or absolute paths) 91 | NormalizedPath := TUtils.NormalizePath(APath); 92 | 93 | // Reject absolute paths 94 | if (Length(NormalizedPath) > 0) and (NormalizedPath[1] = DirectorySeparator) then 95 | begin 96 | Result := False; 97 | Exit; 98 | end; 99 | 100 | {$IFDEF WINDOWS} 101 | // Reject Windows absolute paths (C:\, D:\, etc.) 102 | if (Length(NormalizedPath) > 1) and (NormalizedPath[2] = ':') then 103 | begin 104 | Result := False; 105 | Exit; 106 | end; 107 | {$ENDIF} 108 | 109 | // Reject paths with .. (parent directory) 110 | if Pos('..', NormalizedPath) > 0 then 111 | begin 112 | Result := False; 113 | Exit; 114 | end; 115 | 116 | Result := True; 117 | end; 118 | 119 | procedure TSourcePackageCommand.AddDirectoryToZip(AZip: TZipper; const ASourceDir, AArchivePrefix: string); 120 | var 121 | SearchRec: TSearchRec; 122 | SourcePath, ArchivePath: string; 123 | begin 124 | if FindFirst(ASourceDir + DirectorySeparator + '*', faAnyFile, SearchRec) = 0 then 125 | begin 126 | try 127 | repeat 128 | // Skip . and .. 129 | if (SearchRec.Name = '.') or (SearchRec.Name = '..') then 130 | Continue; 131 | 132 | SourcePath := ASourceDir + DirectorySeparator + SearchRec.Name; 133 | ArchivePath := AArchivePrefix + SearchRec.Name; 134 | 135 | if (SearchRec.Attr and faDirectory) <> 0 then 136 | begin 137 | // Recursively add subdirectory 138 | AddDirectoryToZip(AZip, SourcePath, ArchivePath + DirectorySeparator); 139 | end 140 | else 141 | begin 142 | // Add file 143 | TUtils.LogInfo('Adding to archive: ' + SourcePath); 144 | AZip.Entries.AddFileEntry(SourcePath, ArchivePath); 145 | end; 146 | until FindNext(SearchRec) <> 0; 147 | finally 148 | FindClose(SearchRec); 149 | end; 150 | end; 151 | end; 152 | 153 | function TSourcePackageCommand.CreateSourceArchive(const AArchiveName: string): Integer; 154 | var 155 | Zip: TZipper; 156 | LicenseFile, ReadmeFile, BootstrapFile, InstallFile: string; 157 | IncludeDir: string; 158 | ArchivePrefix: string; 159 | begin 160 | Result := 0; 161 | 162 | // Archive prefix: -/ 163 | ArchivePrefix := Config.Name + '-' + Config.Version + DirectorySeparator; 164 | 165 | Zip := TZipper.Create; 166 | try 167 | Zip.FileName := AArchiveName; 168 | 169 | // DEFAULT INCLUDES 170 | 171 | // 1. src/ directory (all source code) 172 | if DirectoryExists('src') then 173 | begin 174 | TUtils.LogInfo('Adding src/ directory'); 175 | AddDirectoryToZip(Zip, 'src', ArchivePrefix + 'src' + DirectorySeparator); 176 | end 177 | else 178 | TUtils.LogWarning('src/ directory not found'); 179 | 180 | // 2. project.xml 181 | if FileExists('project.xml') then 182 | begin 183 | TUtils.LogInfo('Adding to archive: project.xml'); 184 | Zip.Entries.AddFileEntry('project.xml', ArchivePrefix + 'project.xml'); 185 | end 186 | else 187 | TUtils.LogWarning('project.xml not found'); 188 | 189 | // 3. LICENSE file variants 190 | LicenseFile := FindFileWithVariants('LICENSE'); 191 | if LicenseFile = '' then 192 | LicenseFile := FindFileWithVariants('COPYING'); 193 | 194 | if LicenseFile <> '' then 195 | begin 196 | TUtils.LogInfo('Adding to archive: ' + LicenseFile); 197 | Zip.Entries.AddFileEntry(LicenseFile, ArchivePrefix + ExtractFileName(LicenseFile)); 198 | end 199 | else 200 | TUtils.LogInfo('LICENSE/COPYING file not found, skipping'); 201 | 202 | // 4. README file variants 203 | ReadmeFile := FindFileWithVariants('README'); 204 | if ReadmeFile <> '' then 205 | begin 206 | TUtils.LogInfo('Adding to archive: ' + ReadmeFile); 207 | Zip.Entries.AddFileEntry(ReadmeFile, ArchivePrefix + ExtractFileName(ReadmeFile)); 208 | end 209 | else 210 | TUtils.LogInfo('README file not found, skipping'); 211 | 212 | // 5. BOOTSTRAP file variants 213 | BootstrapFile := FindFileWithVariants('BOOTSTRAP'); 214 | if BootstrapFile <> '' then 215 | begin 216 | TUtils.LogInfo('Adding to archive: ' + BootstrapFile); 217 | Zip.Entries.AddFileEntry(BootstrapFile, ArchivePrefix + ExtractFileName(BootstrapFile)); 218 | end; 219 | 220 | // 6. INSTALL file variants 221 | InstallFile := FindFileWithVariants('INSTALL'); 222 | if InstallFile <> '' then 223 | begin 224 | TUtils.LogInfo('Adding to archive: ' + InstallFile); 225 | Zip.Entries.AddFileEntry(InstallFile, ArchivePrefix + ExtractFileName(InstallFile)); 226 | end; 227 | 228 | // OPTIONAL INCLUDES (from configuration) 229 | 230 | for IncludeDir in Config.SourcePackageConfig.IncludeDirs do 231 | begin 232 | // Security check: Only allow safe paths 233 | if not IsPathSafe(IncludeDir) then 234 | begin 235 | TUtils.LogError('Security violation: Unsafe path in sourcePackage config: ' + IncludeDir); 236 | TUtils.LogError('Paths must be relative and within project root (no .., no absolute paths)'); 237 | Result := 1; 238 | Exit; 239 | end; 240 | 241 | if DirectoryExists(IncludeDir) then 242 | begin 243 | TUtils.LogInfo('Adding configured directory: ' + IncludeDir); 244 | AddDirectoryToZip(Zip, IncludeDir, ArchivePrefix + IncludeDir + DirectorySeparator); 245 | end 246 | else if FileExists(IncludeDir) then 247 | begin 248 | TUtils.LogInfo('Adding configured file: ' + IncludeDir); 249 | Zip.Entries.AddFileEntry(IncludeDir, ArchivePrefix + IncludeDir); 250 | end 251 | else 252 | TUtils.LogWarning('Configured include not found: ' + IncludeDir); 253 | end; 254 | 255 | // Create the archive 256 | if Zip.Entries.Count = 0 then 257 | begin 258 | TUtils.LogError('No files to include in source archive'); 259 | Result := 1; 260 | Exit; 261 | end; 262 | 263 | Zip.ZipAllFiles; 264 | TUtils.LogInfo('Created source archive: ' + AArchiveName); 265 | 266 | finally 267 | Zip.Free; 268 | end; 269 | end; 270 | 271 | function TSourcePackageCommand.Execute: Integer; 272 | var 273 | ArchiveName: string; 274 | OutputDir: string; 275 | begin 276 | Result := 0; 277 | 278 | TUtils.LogInfo('Creating source package...'); 279 | 280 | // Ensure target directory exists 281 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 282 | if not ForceDirectories(OutputDir) then 283 | begin 284 | TUtils.LogError('Failed to create output directory: ' + OutputDir); 285 | Result := 1; 286 | Exit; 287 | end; 288 | 289 | // Get archive filename 290 | ArchiveName := GetArchiveFileName; 291 | 292 | // Delete existing archive if present 293 | if FileExists(ArchiveName) then 294 | begin 295 | TUtils.LogInfo('Removing existing archive: ' + ArchiveName); 296 | if not DeleteFile(ArchiveName) then 297 | begin 298 | TUtils.LogError('Failed to delete existing archive: ' + ArchiveName); 299 | Result := 1; 300 | Exit; 301 | end; 302 | end; 303 | 304 | // Create the source archive 305 | Result := CreateSourceArchive(ArchiveName); 306 | 307 | if Result = 0 then 308 | TUtils.LogInfo('Source package created successfully: ' + ArchiveName) 309 | else 310 | TUtils.LogError('Source package creation failed'); 311 | end; 312 | 313 | end. 314 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Types.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Types; 12 | 13 | {$mode objfpc}{$H+} 14 | {$modeswitch advancedrecords} 15 | 16 | interface 17 | 18 | uses 19 | Classes, SysUtils, fgl; 20 | 21 | type 22 | { Project type enumeration } 23 | TProjectType = (ptApplication, ptLibrary); 24 | 25 | { Test framework enumeration } 26 | TTestFramework = (tfAuto, tfFPCUnit, tfFPTest); 27 | 28 | { Forward declarations } 29 | TProfile = class; 30 | TBuildConfig = class; 31 | TTestConfig = class; 32 | TResourcesConfig = class; 33 | TSourcePackageConfig = class; 34 | TProjectConfig = class; 35 | TConditionalPath = class; 36 | 37 | { TConditionalPath - Represents a path with an optional condition } 38 | TConditionalPath = class 39 | private 40 | FPath: string; 41 | FCondition: string; 42 | public 43 | constructor Create(const APath: string; const ACondition: string = ''); 44 | 45 | property Path: string read FPath write FPath; 46 | property Condition: string read FCondition write FCondition; 47 | end; 48 | 49 | { TConditionalPathList - Strongly-typed collection of conditional paths } 50 | TConditionalPathList = class(specialize TFPGObjectList) 51 | end; 52 | 53 | { TProfile - Represents a build profile with defines and compiler options } 54 | TProfile = class 55 | private 56 | FId: string; 57 | FDefines: TStringList; 58 | FCompilerOptions: TStringList; 59 | public 60 | constructor Create; 61 | destructor Destroy; override; 62 | 63 | property Id: string read FId write FId; 64 | property Defines: TStringList read FDefines; 65 | property CompilerOptions: TStringList read FCompilerOptions; 66 | end; 67 | 68 | { TProfileList - Strongly-typed collection of build profiles using generics } 69 | TProfileList = class(specialize TFPGObjectList) 70 | public 71 | function FindById(const AId: string): TProfile; 72 | end; 73 | 74 | { TBuildConfig - Build configuration section } 75 | TBuildConfig = class 76 | private 77 | FProjectType: TProjectType; 78 | FMainSource: string; 79 | FOutputDirectory: string; 80 | FExecutableName: string; 81 | FDefines: TStringList; 82 | FCompilerOptions: TStringList; 83 | FUnitPaths: TConditionalPathList; 84 | FIncludePaths: TConditionalPathList; 85 | FManualUnitPaths: Boolean; 86 | public 87 | constructor Create; 88 | destructor Destroy; override; 89 | 90 | property ProjectType: TProjectType read FProjectType write FProjectType; 91 | property MainSource: string read FMainSource write FMainSource; 92 | property OutputDirectory: string read FOutputDirectory write FOutputDirectory; 93 | property ExecutableName: string read FExecutableName write FExecutableName; 94 | property Defines: TStringList read FDefines; 95 | property CompilerOptions: TStringList read FCompilerOptions; 96 | property UnitPaths: TConditionalPathList read FUnitPaths; 97 | property IncludePaths: TConditionalPathList read FIncludePaths; 98 | property ManualUnitPaths: Boolean read FManualUnitPaths write FManualUnitPaths; 99 | end; 100 | 101 | { TTestConfig - Test configuration section } 102 | TTestConfig = class 103 | private 104 | FFramework: TTestFramework; 105 | FTestSource: string; 106 | FFrameworkOptions: TStringList; 107 | public 108 | constructor Create; 109 | destructor Destroy; override; 110 | 111 | property Framework: TTestFramework read FFramework write FFramework; 112 | property TestSource: string read FTestSource write FTestSource; 113 | property FrameworkOptions: TStringList read FFrameworkOptions; 114 | end; 115 | 116 | { TResourcesConfig - Resources configuration section } 117 | TResourcesConfig = class 118 | private 119 | FDirectory: string; 120 | FFiltering: Boolean; 121 | public 122 | constructor Create; 123 | 124 | property Directory: string read FDirectory write FDirectory; 125 | property Filtering: Boolean read FFiltering write FFiltering; 126 | end; 127 | 128 | { TSourcePackageConfig - Source package configuration section } 129 | TSourcePackageConfig = class 130 | private 131 | FIncludeDirs: TStringList; 132 | public 133 | constructor Create; 134 | destructor Destroy; override; 135 | 136 | property IncludeDirs: TStringList read FIncludeDirs; 137 | end; 138 | 139 | { TProjectConfig - Complete project configuration } 140 | TProjectConfig = class 141 | private 142 | FName: string; 143 | FVersion: string; 144 | FAuthor: string; 145 | FLicense: string; 146 | FProjectUrl: string; 147 | FRepoUrl: string; 148 | FBuildConfig: TBuildConfig; 149 | FTestConfig: TTestConfig; 150 | FResourcesConfig: TResourcesConfig; 151 | FTestResourcesConfig: TResourcesConfig; 152 | FSourcePackageConfig: TSourcePackageConfig; 153 | FProfiles: TProfileList; 154 | public 155 | constructor Create; 156 | destructor Destroy; override; 157 | 158 | property Name: string read FName write FName; 159 | property Version: string read FVersion write FVersion; 160 | property Author: string read FAuthor write FAuthor; 161 | property License: string read FLicense write FLicense; 162 | property ProjectUrl: string read FProjectUrl write FProjectUrl; 163 | property RepoUrl: string read FRepoUrl write FRepoUrl; 164 | property BuildConfig: TBuildConfig read FBuildConfig; 165 | property TestConfig: TTestConfig read FTestConfig; 166 | property ResourcesConfig: TResourcesConfig read FResourcesConfig; 167 | property TestResourcesConfig: TResourcesConfig read FTestResourcesConfig; 168 | property SourcePackageConfig: TSourcePackageConfig read FSourcePackageConfig; 169 | property Profiles: TProfileList read FProfiles; 170 | end; 171 | 172 | implementation 173 | 174 | { TConditionalPath } 175 | 176 | constructor TConditionalPath.Create(const APath: string; const ACondition: string = ''); 177 | begin 178 | inherited Create; 179 | FPath := APath; 180 | FCondition := ACondition; 181 | end; 182 | 183 | { TProfile } 184 | 185 | constructor TProfile.Create; 186 | begin 187 | inherited Create; 188 | FDefines := TStringList.Create; 189 | FDefines.Duplicates := dupIgnore; 190 | FDefines.Sorted := True; 191 | 192 | FCompilerOptions := TStringList.Create; 193 | FCompilerOptions.Duplicates := dupIgnore; 194 | end; 195 | 196 | destructor TProfile.Destroy; 197 | begin 198 | FDefines.Free; 199 | FCompilerOptions.Free; 200 | inherited Destroy; 201 | end; 202 | 203 | { TProfileList } 204 | 205 | function TProfileList.FindById(const AId: string): TProfile; 206 | var 207 | Profile: TProfile; 208 | begin 209 | Result := nil; 210 | for Profile in Self do 211 | begin 212 | if Profile.Id = AId then 213 | begin 214 | Result := Profile; 215 | Exit; 216 | end; 217 | end; 218 | end; 219 | 220 | { TBuildConfig } 221 | 222 | constructor TBuildConfig.Create; 223 | begin 224 | inherited Create; 225 | FDefines := TStringList.Create; 226 | FDefines.Duplicates := dupIgnore; 227 | FDefines.Sorted := True; 228 | 229 | FCompilerOptions := TStringList.Create; 230 | FCompilerOptions.Duplicates := dupIgnore; 231 | 232 | FUnitPaths := TConditionalPathList.Create; 233 | FUnitPaths.FreeObjects := True; 234 | 235 | FIncludePaths := TConditionalPathList.Create; 236 | FIncludePaths.FreeObjects := True; 237 | 238 | // Set defaults 239 | FProjectType := ptApplication; // Application by default 240 | FOutputDirectory := 'target'; 241 | FManualUnitPaths := False; // Auto-scan by default 242 | end; 243 | 244 | destructor TBuildConfig.Destroy; 245 | begin 246 | FDefines.Free; 247 | FCompilerOptions.Free; 248 | FUnitPaths.Free; 249 | FIncludePaths.Free; 250 | inherited Destroy; 251 | end; 252 | 253 | { TTestConfig } 254 | 255 | constructor TTestConfig.Create; 256 | begin 257 | inherited Create; 258 | FFrameworkOptions := TStringList.Create; 259 | FFrameworkOptions.Duplicates := dupIgnore; 260 | 261 | // Set defaults 262 | FFramework := tfAuto; // Auto-detect by default 263 | FTestSource := 'TestRunner.pas'; // Common default name 264 | end; 265 | 266 | destructor TTestConfig.Destroy; 267 | begin 268 | FFrameworkOptions.Free; 269 | inherited Destroy; 270 | end; 271 | 272 | { TResourcesConfig } 273 | 274 | constructor TResourcesConfig.Create; 275 | begin 276 | inherited Create; 277 | FDirectory := 'src/main/resources'; // Default 278 | FFiltering := False; // Default: no filtering 279 | end; 280 | 281 | { TSourcePackageConfig } 282 | 283 | constructor TSourcePackageConfig.Create; 284 | begin 285 | inherited Create; 286 | FIncludeDirs := TStringList.Create; 287 | FIncludeDirs.Duplicates := dupIgnore; 288 | end; 289 | 290 | destructor TSourcePackageConfig.Destroy; 291 | begin 292 | FIncludeDirs.Free; 293 | inherited Destroy; 294 | end; 295 | 296 | { TProjectConfig } 297 | 298 | constructor TProjectConfig.Create; 299 | begin 300 | inherited Create; 301 | FBuildConfig := TBuildConfig.Create; 302 | FTestConfig := TTestConfig.Create; 303 | FResourcesConfig := TResourcesConfig.Create; 304 | FTestResourcesConfig := TResourcesConfig.Create; 305 | FTestResourcesConfig.Directory := 'src/test/resources'; // Override default for test resources 306 | FSourcePackageConfig := TSourcePackageConfig.Create; 307 | FProfiles := TProfileList.Create; 308 | FProfiles.FreeObjects := True; 309 | 310 | // Set defaults 311 | FAuthor := 'Unknown'; 312 | FLicense := 'Proprietary'; 313 | end; 314 | 315 | destructor TProjectConfig.Destroy; 316 | begin 317 | FBuildConfig.Free; 318 | FTestConfig.Free; 319 | FResourcesConfig.Free; 320 | FTestResourcesConfig.Free; 321 | FSourcePackageConfig.Free; 322 | FProfiles.Free; 323 | inherited Destroy; 324 | end; 325 | 326 | end. 327 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Bootstrap.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Bootstrap; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, StrUtils, 19 | PasBuild.Types, 20 | PasBuild.Utils; 21 | 22 | type 23 | { Bootstrap program generator for library projects } 24 | TBootstrapGenerator = class 25 | public 26 | class function GenerateBootstrapProgram( 27 | AConfig: TProjectConfig; 28 | const AOutputPath: string; 29 | AActiveDefines: TStringList 30 | ): Boolean; 31 | private 32 | class function DiscoverUnits( 33 | const ABasePath: string; 34 | AUnitPaths: TConditionalPathList; 35 | AManualMode: Boolean; 36 | AActiveDefines: TStringList 37 | ): TStringList; 38 | class function ParseUnitName(const AFilePath: string): string; 39 | end; 40 | 41 | implementation 42 | 43 | { TBootstrapGenerator } 44 | 45 | class function TBootstrapGenerator.ParseUnitName(const AFilePath: string): string; 46 | var 47 | F: TextFile; 48 | Line: string; 49 | UnitPos: Integer; 50 | InBraceComment: Boolean; 51 | InParenComment: Boolean; 52 | CommentEndPos: Integer; 53 | begin 54 | Result := ''; 55 | 56 | if not FileExists(AFilePath) then 57 | Exit; 58 | 59 | try 60 | AssignFile(F, AFilePath); 61 | Reset(F); 62 | try 63 | InBraceComment := False; 64 | InParenComment := False; 65 | 66 | // Read first few lines looking for "unit ;" 67 | while not EOF(F) and (Result = '') do 68 | begin 69 | ReadLn(F, Line); 70 | Line := Trim(Line); 71 | 72 | // Skip empty lines 73 | if Line = '' then 74 | Continue; 75 | 76 | // Track multi-line comment state for { ... } 77 | if InBraceComment then 78 | begin 79 | CommentEndPos := Pos('}', Line); 80 | if CommentEndPos > 0 then 81 | begin 82 | Delete(Line, 1, CommentEndPos); // Remove everything up to and including } 83 | Line := Trim(Line); 84 | InBraceComment := False; 85 | if Line = '' then 86 | Continue; 87 | end 88 | else 89 | Continue; // Still inside comment block 90 | end; 91 | 92 | // Track multi-line comment state for (* ... *) 93 | if InParenComment then 94 | begin 95 | CommentEndPos := Pos('*)', Line); 96 | if CommentEndPos > 0 then 97 | begin 98 | Delete(Line, 1, CommentEndPos + 1); // Remove everything up to and including *) 99 | Line := Trim(Line); 100 | InParenComment := False; 101 | if Line = '' then 102 | Continue; 103 | end 104 | else 105 | Continue; // Still inside comment block 106 | end; 107 | 108 | // Check if line starts a multi-line comment 109 | if AnsiStartsStr('{', Line) then 110 | begin 111 | CommentEndPos := Pos('}', Line); 112 | if CommentEndPos > 0 then 113 | begin 114 | // Single-line comment, remove it 115 | Delete(Line, 1, CommentEndPos); 116 | Line := Trim(Line); 117 | if Line = '' then 118 | Continue; 119 | end 120 | else 121 | begin 122 | InBraceComment := True; 123 | Continue; 124 | end; 125 | end; 126 | 127 | if AnsiStartsStr('(*', Line) then 128 | begin 129 | CommentEndPos := Pos('*)', Line); 130 | if CommentEndPos > 0 then 131 | begin 132 | // Single-line comment, remove it 133 | Delete(Line, 1, CommentEndPos + 1); 134 | Line := Trim(Line); 135 | if Line = '' then 136 | Continue; 137 | end 138 | else 139 | begin 140 | InParenComment := True; 141 | Continue; 142 | end; 143 | end; 144 | 145 | // Skip single-line // comments 146 | if AnsiStartsStr('//', Line) then 147 | Continue; 148 | 149 | // Look for "unit ;" 150 | if AnsiStartsStr('unit ', LowerCase(Line)) then 151 | begin 152 | UnitPos := Pos('unit ', LowerCase(Line)); 153 | if UnitPos > 0 then 154 | begin 155 | Delete(Line, 1, UnitPos + 4); // Remove "unit " 156 | Line := Trim(Line); 157 | 158 | // Remove trailing semicolon 159 | if AnsiEndsStr(';', Line) then 160 | Delete(Line, Length(Line), 1); 161 | 162 | Result := Trim(Line); 163 | end; 164 | end; 165 | end; 166 | finally 167 | CloseFile(F); 168 | end; 169 | except 170 | // If we can't read the file, skip it 171 | Result := ''; 172 | end; 173 | end; 174 | 175 | class function TBootstrapGenerator.DiscoverUnits( 176 | const ABasePath: string; 177 | AUnitPaths: TConditionalPathList; 178 | AManualMode: Boolean; 179 | AActiveDefines: TStringList 180 | ): TStringList; 181 | var 182 | AllDirs, Units: TStringList; 183 | Dir, UnitFile, DiscoveredUnit: string; 184 | SearchRec: TSearchRec; 185 | ConditionalPath: TConditionalPath; 186 | I: Integer; 187 | begin 188 | Result := TStringList.Create; 189 | Result.Duplicates := dupIgnore; 190 | Result.Sorted := True; 191 | 192 | Units := TStringList.Create; 193 | try 194 | if AManualMode then 195 | begin 196 | // Manual mode: Only check explicitly listed paths 197 | Units.Add(ExcludeTrailingPathDelimiter(ABasePath)); // Base directory 198 | 199 | for I := 0 to AUnitPaths.Count - 1 do 200 | begin 201 | ConditionalPath := AUnitPaths[I]; 202 | if TUtils.IsConditionMet(ConditionalPath.Condition, AActiveDefines) then 203 | Units.Add(ABasePath + DirectorySeparator + TUtils.NormalizePath(ConditionalPath.Path)); 204 | end; 205 | end 206 | else 207 | begin 208 | // Auto-scan mode: Scan all directories with conditional filtering 209 | AllDirs := TUtils.ScanForUnitPathsFiltered(ABasePath, AUnitPaths, AActiveDefines); 210 | try 211 | Units.Add(ExcludeTrailingPathDelimiter(ABasePath)); // Base directory 212 | Units.AddStrings(AllDirs); 213 | finally 214 | AllDirs.Free; 215 | end; 216 | end; 217 | 218 | // Scan each directory for .pas files 219 | for Dir in Units do 220 | begin 221 | if FindFirst(IncludeTrailingPathDelimiter(Dir) + '*.pas', faAnyFile and not faDirectory, SearchRec) = 0 then 222 | begin 223 | try 224 | repeat 225 | UnitFile := IncludeTrailingPathDelimiter(Dir) + SearchRec.Name; 226 | DiscoveredUnit := ParseUnitName(UnitFile); 227 | 228 | if DiscoveredUnit <> '' then 229 | Result.Add(DiscoveredUnit); 230 | until FindNext(SearchRec) <> 0; 231 | finally 232 | FindClose(SearchRec); 233 | end; 234 | end; 235 | end; 236 | finally 237 | Units.Free; 238 | end; 239 | end; 240 | 241 | class function TBootstrapGenerator.GenerateBootstrapProgram( 242 | AConfig: TProjectConfig; 243 | const AOutputPath: string; 244 | AActiveDefines: TStringList 245 | ): Boolean; 246 | var 247 | Units: TStringList; 248 | BootstrapCode: string; 249 | F: TextFile; 250 | CurrentUnitName: string; 251 | I: Integer; 252 | BasePath: string; 253 | begin 254 | Result := False; 255 | 256 | TUtils.LogInfo('Generating bootstrap program for library...'); 257 | 258 | // Discover all units 259 | BasePath := TUtils.NormalizePath('src/main/pascal'); 260 | Units := DiscoverUnits( 261 | BasePath, 262 | AConfig.BuildConfig.UnitPaths, 263 | AConfig.BuildConfig.ManualUnitPaths, 264 | AActiveDefines 265 | ); 266 | 267 | try 268 | if Units.Count = 0 then 269 | begin 270 | TUtils.LogWarning('No units found for bootstrap program'); 271 | Exit; 272 | end; 273 | 274 | TUtils.LogInfo('Discovered ' + IntToStr(Units.Count) + ' units'); 275 | 276 | // Generate bootstrap program code 277 | BootstrapCode := 'program bootstrap_program;' + LineEnding + 278 | LineEnding + 279 | '{$mode objfpc}{$H+}' + LineEnding + 280 | LineEnding + 281 | '{ Auto-generated bootstrap program for library compilation }' + LineEnding + 282 | '{ Generated by PasBuild - DO NOT EDIT }' + LineEnding + 283 | LineEnding + 284 | 'uses' + LineEnding; 285 | 286 | for I := 0 to Units.Count - 1 do 287 | begin 288 | CurrentUnitName := Units[I]; 289 | if I < Units.Count - 1 then 290 | BootstrapCode := BootstrapCode + ' ' + CurrentUnitName + ',' + LineEnding 291 | else 292 | BootstrapCode := BootstrapCode + ' ' + CurrentUnitName + ';' + LineEnding; 293 | end; 294 | 295 | BootstrapCode := BootstrapCode + LineEnding + 296 | 'begin' + LineEnding + 297 | ' WriteLn(''Library bootstrap compilation successful: ' + AConfig.Name + ''');' + LineEnding + 298 | 'end.' + LineEnding; 299 | 300 | // Write to file 301 | try 302 | AssignFile(F, AOutputPath); 303 | Rewrite(F); 304 | try 305 | Write(F, BootstrapCode); 306 | finally 307 | CloseFile(F); 308 | end; 309 | 310 | TUtils.LogInfo('Bootstrap program generated: ' + AOutputPath); 311 | Result := True; 312 | except 313 | on E: Exception do 314 | begin 315 | TUtils.LogError('Failed to write bootstrap program: ' + E.Message); 316 | Result := False; 317 | end; 318 | end; 319 | 320 | finally 321 | Units.Free; 322 | end; 323 | end; 324 | 325 | end. 326 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.Compile.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.Compile; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, 19 | PasBuild.Types, 20 | PasBuild.Command, 21 | PasBuild.Utils, 22 | PasBuild.Bootstrap; 23 | 24 | type 25 | { Compile command - builds the executable } 26 | TCompileCommand = class(TBuildCommand) 27 | protected 28 | function GetName: string; override; 29 | function BuildCompilerCommand(const ASourcePath: string): string; 30 | public 31 | function Execute: Integer; override; 32 | function GetDependencies: TBuildCommandList; override; 33 | end; 34 | 35 | implementation 36 | 37 | uses 38 | PasBuild.Command.ProcessResources; 39 | 40 | { TCompileCommand } 41 | 42 | function TCompileCommand.GetName: string; 43 | begin 44 | Result := 'compile'; 45 | end; 46 | 47 | function TCompileCommand.BuildCompilerCommand(const ASourcePath: string): string; 48 | var 49 | OutputDir, ExeName: string; 50 | UnitPaths, IncludePaths, ActiveDefines: TStringList; 51 | UnitPath, IncludePath, Define, Option, ProfileId: string; 52 | Profile: TProfile; 53 | ConditionalPath: TConditionalPath; 54 | BasePath: string; 55 | I: Integer; 56 | begin 57 | // Base command with default flags 58 | Result := 'fpc -Mobjfpc -O1'; 59 | 60 | // Source file path (passed as parameter) 61 | Result := Result + ' ' + ASourcePath; 62 | 63 | // Output directory 64 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 65 | Result := Result + ' -FE' + OutputDir; 66 | 67 | // Unit output directory 68 | Result := Result + ' -FU' + OutputDir + DirectorySeparator + 'units'; 69 | 70 | // Executable name 71 | ExeName := Config.BuildConfig.ExecutableName; 72 | if ExeName <> '' then 73 | Result := Result + ' -o' + ExeName + TUtils.GetPlatformExecutableSuffix; 74 | 75 | // Collect all active defines (global + profile) 76 | ActiveDefines := TStringList.Create; 77 | try 78 | ActiveDefines.Duplicates := dupIgnore; 79 | ActiveDefines.Sorted := True; 80 | 81 | // Add global defines 82 | ActiveDefines.AddStrings(Config.BuildConfig.Defines); 83 | 84 | // Add profile defines for each active profile (applied in order) 85 | for ProfileId in ProfileIds do 86 | begin 87 | Profile := Config.Profiles.FindById(ProfileId); 88 | if Assigned(Profile) then 89 | begin 90 | TUtils.LogInfo('Activating profile: ' + ProfileId); 91 | ActiveDefines.AddStrings(Profile.Defines); 92 | end 93 | else 94 | TUtils.LogWarning('Profile not found: ' + ProfileId); 95 | end; 96 | 97 | // Add unit search paths (-Fu) 98 | BasePath := TUtils.NormalizePath('src/main/pascal'); 99 | 100 | if not Config.BuildConfig.ManualUnitPaths then 101 | // Always add the base source directory first 102 | Result := Result + ' -Fu' + BasePath; 103 | 104 | if Config.BuildConfig.ManualUnitPaths then 105 | begin 106 | // Manual mode: Only use paths explicitly listed in 107 | // Apply conditional filtering to manual paths 108 | UnitPaths := TStringList.Create; 109 | try 110 | UnitPaths.Duplicates := dupIgnore; 111 | UnitPaths.Sorted := True; 112 | 113 | for I := 0 to Config.BuildConfig.UnitPaths.Count - 1 do 114 | begin 115 | ConditionalPath := Config.BuildConfig.UnitPaths[I]; 116 | if TUtils.IsConditionMet(ConditionalPath.Condition, ActiveDefines) then 117 | UnitPaths.Add(TUtils.NormalizePath(ConditionalPath.Path)); 118 | end; 119 | 120 | for UnitPath in UnitPaths do 121 | Result := Result + ' -Fu' + UnitPath; 122 | finally 123 | UnitPaths.Free; 124 | end; 125 | end 126 | else 127 | begin 128 | // Auto-scan mode (default): Scan all directories, apply conditional filtering 129 | UnitPaths := TUtils.ScanForUnitPathsFiltered( 130 | BasePath, 131 | Config.BuildConfig.UnitPaths, 132 | ActiveDefines 133 | ); 134 | try 135 | for UnitPath in UnitPaths do 136 | Result := Result + ' -Fu' + UnitPath; 137 | finally 138 | UnitPaths.Free; 139 | end; 140 | end; 141 | 142 | // Add include search paths (-Fi) 143 | // Always add output directory first (for filtered resource includes like version.inc) 144 | Result := Result + ' -Fi' + OutputDir; 145 | 146 | // Check all unit paths (both base and subdirs) for *.inc files 147 | IncludePaths := TStringList.Create; 148 | try 149 | IncludePaths.Duplicates := dupIgnore; 150 | IncludePaths.Sorted := True; 151 | 152 | if Config.BuildConfig.ManualUnitPaths then 153 | begin 154 | // Manual mode: Check base path + manually listed paths for *.inc files 155 | // Check base directory 156 | if TUtils.DirectoryContainsIncludeFiles(BasePath) then 157 | IncludePaths.Add(BasePath); 158 | 159 | // Check each manually specified unit path 160 | for I := 0 to Config.BuildConfig.UnitPaths.Count - 1 do 161 | begin 162 | ConditionalPath := Config.BuildConfig.UnitPaths[I]; 163 | if TUtils.IsConditionMet(ConditionalPath.Condition, ActiveDefines) then 164 | begin 165 | UnitPath := TUtils.NormalizePath(ConditionalPath.Path); 166 | if TUtils.DirectoryContainsIncludeFiles(UnitPath) then 167 | IncludePaths.Add(UnitPath); 168 | end; 169 | end; 170 | end 171 | else 172 | begin 173 | // Auto-scan mode: Use full auto-scan with conditional filtering 174 | IncludePaths.Free; 175 | // Use IncludePaths if specified, otherwise fall back to UnitPaths 176 | if Config.BuildConfig.IncludePaths.Count > 0 then 177 | IncludePaths := TUtils.ScanForIncludePathsFiltered( 178 | BasePath, 179 | Config.BuildConfig.IncludePaths, 180 | ActiveDefines 181 | ) 182 | else 183 | IncludePaths := TUtils.ScanForIncludePathsFiltered( 184 | BasePath, 185 | Config.BuildConfig.UnitPaths, 186 | ActiveDefines 187 | ); 188 | end; 189 | 190 | for IncludePath in IncludePaths do 191 | Result := Result + ' -Fi' + IncludePath; 192 | finally 193 | IncludePaths.Free; 194 | end; 195 | 196 | // Add global defines to compiler 197 | for Define in Config.BuildConfig.Defines do 198 | Result := Result + ' -d' + Define; 199 | 200 | // Add global compiler options (extends defaults) 201 | for Option in Config.BuildConfig.CompilerOptions do 202 | Result := Result + ' ' + Option; 203 | 204 | // Add profile-specific defines and compiler options (applied in order) 205 | for ProfileId in ProfileIds do 206 | begin 207 | Profile := Config.Profiles.FindById(ProfileId); 208 | if Assigned(Profile) then 209 | begin 210 | // Profile defines 211 | for Define in Profile.Defines do 212 | Result := Result + ' -d' + Define; 213 | 214 | // Profile compiler options (these can override defaults and global options) 215 | for Option in Profile.CompilerOptions do 216 | Result := Result + ' ' + Option; 217 | end; 218 | end; 219 | 220 | finally 221 | ActiveDefines.Free; 222 | end; 223 | end; 224 | 225 | function TCompileCommand.Execute: Integer; 226 | var 227 | MainSourcePath, OutputDir, UnitsDir, BootstrapPath, ProfileId: string; 228 | Command: string; 229 | ActiveDefines: TStringList; 230 | Profile: TProfile; 231 | StatusDir, LogFile: string; 232 | SourceFiles, IncludeFiles: TStringList; 233 | begin 234 | Result := 0; 235 | 236 | TUtils.LogInfo('Compiling project...'); 237 | 238 | // Verify directory layout 239 | if not TUtils.VerifyDirectoryLayout('.') then 240 | begin 241 | Result := 1; 242 | Exit; 243 | end; 244 | 245 | // Create output directories first (needed for bootstrap generation) 246 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 247 | UnitsDir := OutputDir + DirectorySeparator + 'units'; 248 | 249 | if not ForceDirectories(OutputDir) then 250 | begin 251 | TUtils.LogError('Failed to create output directory: ' + OutputDir); 252 | Result := 1; 253 | Exit; 254 | end; 255 | 256 | if not ForceDirectories(UnitsDir) then 257 | begin 258 | TUtils.LogError('Failed to create units directory: ' + UnitsDir); 259 | Result := 1; 260 | Exit; 261 | end; 262 | 263 | // For library projects, generate bootstrap program 264 | if Config.BuildConfig.ProjectType = ptLibrary then 265 | begin 266 | BootstrapPath := OutputDir + DirectorySeparator + 'bootstrap_program.pas'; 267 | 268 | // Collect active defines (same logic as BuildCompilerCommand) 269 | ActiveDefines := TStringList.Create; 270 | try 271 | ActiveDefines.Duplicates := dupIgnore; 272 | ActiveDefines.Sorted := True; 273 | ActiveDefines.AddStrings(Config.BuildConfig.Defines); 274 | 275 | // Add defines from each active profile in order 276 | for ProfileId in ProfileIds do 277 | begin 278 | Profile := Config.Profiles.FindById(ProfileId); 279 | if Assigned(Profile) then 280 | ActiveDefines.AddStrings(Profile.Defines); 281 | end; 282 | 283 | if not TBootstrapGenerator.GenerateBootstrapProgram(Config, BootstrapPath, ActiveDefines) then 284 | begin 285 | TUtils.LogError('Failed to generate bootstrap program'); 286 | Result := 1; 287 | Exit; 288 | end; 289 | finally 290 | ActiveDefines.Free; 291 | end; 292 | 293 | // Use bootstrap program as main source 294 | MainSourcePath := BootstrapPath; 295 | end 296 | else 297 | begin 298 | // Application project: Check if main source file exists 299 | MainSourcePath := TUtils.NormalizePath('src/main/pascal/' + Config.BuildConfig.MainSource); 300 | if not FileExists(MainSourcePath) then 301 | begin 302 | TUtils.LogError('Main source file not found: ' + MainSourcePath); 303 | Result := 1; 304 | Exit; 305 | end; 306 | end; 307 | 308 | // Check if FPC is available 309 | if not TUtils.IsFPCAvailable then 310 | begin 311 | TUtils.LogError('Free Pascal Compiler (fpc) not found in PATH'); 312 | TUtils.LogError('Please install FPC or add it to your PATH'); 313 | Result := 1; 314 | Exit; 315 | end; 316 | 317 | // Create status directory and collect source information 318 | try 319 | StatusDir := TUtils.CreateStatusDirectory('compile'); 320 | 321 | // Collect and write source files list 322 | SourceFiles := TUtils.CollectSourceFiles(TUtils.NormalizePath('src/main/pascal')); 323 | try 324 | TUtils.LogInfo('Found ' + IntToStr(SourceFiles.Count) + ' source file(s)'); 325 | TUtils.WriteListFile(StatusDir + DirectorySeparator + 'inputUnits.lst', SourceFiles); 326 | finally 327 | SourceFiles.Free; 328 | end; 329 | 330 | // Collect and write include files list 331 | IncludeFiles := TUtils.CollectIncludeFiles(TUtils.NormalizePath('src/main/pascal')); 332 | try 333 | if IncludeFiles.Count > 0 then 334 | TUtils.LogInfo('Found ' + IntToStr(IncludeFiles.Count) + ' include file(s)'); 335 | TUtils.WriteListFile(StatusDir + DirectorySeparator + 'inputIncludeFiles.lst', IncludeFiles); 336 | finally 337 | IncludeFiles.Free; 338 | end; 339 | 340 | LogFile := StatusDir + DirectorySeparator + 'fpc.log'; 341 | except 342 | on E: Exception do 343 | begin 344 | TUtils.LogWarning('Failed to create status directory: ' + E.Message); 345 | // Continue without status tracking 346 | StatusDir := ''; 347 | end; 348 | end; 349 | 350 | // Build compiler command 351 | Command := BuildCompilerCommand(MainSourcePath); 352 | 353 | // Execute FPC (verbose mode shows full output, quiet mode logs to file) 354 | if FVerbose then 355 | begin 356 | // Verbose mode: Show full FPC output to console 357 | TUtils.LogInfo('Build command: ' + Command); 358 | WriteLn; 359 | Result := TUtils.ExecuteProcess(Command, True); 360 | WriteLn; 361 | if Result = 0 then 362 | TUtils.LogInfo('Build successful') 363 | else 364 | TUtils.LogError('Build failed with exit code: ' + IntToStr(Result)); 365 | end 366 | else 367 | begin 368 | // Quiet mode: Clean console output, full output logged to file 369 | if StatusDir <> '' then 370 | begin 371 | Result := TUtils.ExecuteProcessWithLog(Command, LogFile, True); 372 | if Result = 0 then 373 | TUtils.LogInfo('Build successful (see ' + LogFile + ' for details)') 374 | else 375 | TUtils.LogError('Build failed with exit code: ' + IntToStr(Result) + ' (see ' + LogFile + ' for details)'); 376 | end 377 | else 378 | begin 379 | // Fallback if status directory creation failed 380 | TUtils.LogInfo('Build command: ' + Command); 381 | WriteLn; 382 | Result := TUtils.ExecuteProcess(Command, True); 383 | WriteLn; 384 | if Result = 0 then 385 | TUtils.LogInfo('Build successful') 386 | else 387 | TUtils.LogError('Build failed with exit code: ' + IntToStr(Result)); 388 | end; 389 | end; 390 | end; 391 | 392 | function TCompileCommand.GetDependencies: TBuildCommandList; 393 | begin 394 | Result := TBuildCommandList.Create(False); 395 | try 396 | // compile depends on: process-resources 397 | Result.Add(TProcessResourcesCommand.Create(Config, Config.ResourcesConfig, Config.BuildConfig.OutputDirectory)); 398 | except 399 | Result.Free; 400 | raise; 401 | end; 402 | end; 403 | 404 | end. 405 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Command.Test.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Command.Test; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, 19 | PasBuild.Types, 20 | PasBuild.Command, 21 | PasBuild.Command.Compile, 22 | PasBuild.Command.ProcessTestResources, 23 | PasBuild.Utils; 24 | 25 | type 26 | { Test-compile command - compiles test code } 27 | TTestCompileCommand = class(TBuildCommand) 28 | protected 29 | function GetName: string; override; 30 | function DetectTestFramework: TTestFramework; 31 | function BuildTestCompilerCommand(const ATestSourcePath: string): string; 32 | public 33 | function Execute: Integer; override; 34 | function GetDependencies: TBuildCommandList; override; 35 | end; 36 | 37 | { Test command - runs compiled tests } 38 | TTestCommand = class(TBuildCommand) 39 | protected 40 | function GetName: string; override; 41 | function BuildTestRunnerCommand(const ATestExecutable: string): string; 42 | public 43 | function Execute: Integer; override; 44 | function GetDependencies: TBuildCommandList; override; 45 | end; 46 | 47 | implementation 48 | 49 | { TTestCompileCommand } 50 | 51 | function TTestCompileCommand.GetName: string; 52 | begin 53 | Result := 'test-compile'; 54 | end; 55 | 56 | function TTestCompileCommand.GetDependencies: TBuildCommandList; 57 | begin 58 | Result := TBuildCommandList.Create(False); 59 | try 60 | // test-compile depends on: compile, process-test-resources 61 | Result.Add(TCompileCommand.Create(Config, ProfileIds)); 62 | Result.Add(TProcessTestResourcesCommand.Create(Config, Config.TestResourcesConfig, Config.BuildConfig.OutputDirectory)); 63 | except 64 | Result.Free; 65 | raise; 66 | end; 67 | end; 68 | 69 | function TTestCompileCommand.DetectTestFramework: TTestFramework; 70 | var 71 | TestSourcePath: string; 72 | SourceLines: TStringList; 73 | FullSource: string; 74 | SourceUpper: string; 75 | begin 76 | Result := tfFPCUnit; // Default fallback 77 | 78 | TestSourcePath := TUtils.NormalizePath('src/test/pascal/' + Config.TestConfig.TestSource); 79 | 80 | if not FileExists(TestSourcePath) then 81 | begin 82 | TUtils.LogWarning('Test source not found: ' + TestSourcePath + ', defaulting to FPCUnit'); 83 | Exit; 84 | end; 85 | 86 | // Read entire test source file as one string for multi-line uses clause support 87 | SourceLines := TStringList.Create; 88 | try 89 | try 90 | SourceLines.LoadFromFile(TestSourcePath); 91 | FullSource := SourceLines.Text; 92 | SourceUpper := UpperCase(FullSource); 93 | 94 | // Check for FPCUnit indicator: 'fpcunit' anywhere in the file 95 | // (typically in uses clause, but checking whole file is safer) 96 | if Pos('FPCUNIT', SourceUpper) > 0 then 97 | begin 98 | TUtils.LogInfo('Auto-detected test framework: FPCUnit'); 99 | Result := tfFPCUnit; 100 | Exit; 101 | end; 102 | 103 | // Check for FPTest indicator: 'TestFramework' unit 104 | if Pos('TESTFRAMEWORK', SourceUpper) > 0 then 105 | begin 106 | TUtils.LogInfo('Auto-detected test framework: FPTest'); 107 | Result := tfFPTest; 108 | Exit; 109 | end; 110 | 111 | // No framework detected, default to FPCUnit 112 | TUtils.LogWarning('Could not auto-detect test framework, defaulting to FPCUnit'); 113 | 114 | except 115 | on E: Exception do 116 | begin 117 | TUtils.LogWarning('Error reading test source: ' + E.Message + ', defaulting to FPCUnit'); 118 | end; 119 | end; 120 | 121 | finally 122 | SourceLines.Free; 123 | end; 124 | end; 125 | 126 | function TTestCompileCommand.BuildTestCompilerCommand(const ATestSourcePath: string): string; 127 | var 128 | OutputDir: string; 129 | UnitPaths, IncludePaths: TStringList; 130 | UnitPath, IncludePath, Define, Option: string; 131 | Profile: TProfile; 132 | ProfileId: string; 133 | ActiveDefines: TStringList; 134 | TestBaseDir: string; 135 | begin 136 | // Base command with default flags 137 | Result := 'fpc -Mobjfpc -O1'; 138 | 139 | // Test source file path 140 | Result := Result + ' ' + ATestSourcePath; 141 | 142 | // Output directory for test executable 143 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 144 | Result := Result + ' -FE' + OutputDir; 145 | 146 | // Test unit output directory (separate from main units) 147 | Result := Result + ' -FU' + OutputDir + DirectorySeparator + 'test-units'; 148 | 149 | // Test executable name 150 | Result := Result + ' -oTestRunner' + TUtils.GetPlatformExecutableSuffix; 151 | 152 | // Link to already-compiled main units (compiled by 'compile' goal) 153 | Result := Result + ' -Fu' + OutputDir + DirectorySeparator + 'units'; 154 | 155 | // Add test source directory and its subdirectories 156 | TestBaseDir := TUtils.NormalizePath('src/test/pascal'); 157 | Result := Result + ' -Fu' + TestBaseDir; 158 | 159 | // Scan and add test subdirectories (unit paths) 160 | UnitPaths := TUtils.ScanForUnitPaths(TestBaseDir); 161 | try 162 | for UnitPath in UnitPaths do 163 | Result := Result + ' -Fu' + UnitPath; 164 | finally 165 | UnitPaths.Free; 166 | end; 167 | 168 | // Scan and add include paths for test directory 169 | IncludePaths := TUtils.ScanForIncludePaths(TestBaseDir); 170 | try 171 | for IncludePath in IncludePaths do 172 | Result := Result + ' -Fi' + IncludePath; 173 | finally 174 | IncludePaths.Free; 175 | end; 176 | 177 | // Collect active defines (global + profile) 178 | ActiveDefines := TStringList.Create; 179 | try 180 | ActiveDefines.Duplicates := dupIgnore; 181 | ActiveDefines.Sorted := True; 182 | 183 | // Add global defines 184 | ActiveDefines.AddStrings(Config.BuildConfig.Defines); 185 | 186 | // Add profile defines 187 | for ProfileId in ProfileIds do 188 | begin 189 | Profile := Config.Profiles.FindById(ProfileId); 190 | if Assigned(Profile) then 191 | ActiveDefines.AddStrings(Profile.Defines); 192 | end; 193 | 194 | // Add defines to compiler command 195 | for Define in ActiveDefines do 196 | Result := Result + ' -d' + Define; 197 | 198 | finally 199 | ActiveDefines.Free; 200 | end; 201 | 202 | // Add global compiler options 203 | for Option in Config.BuildConfig.CompilerOptions do 204 | Result := Result + ' ' + Option; 205 | 206 | // Add profile-specific compiler options 207 | for ProfileId in ProfileIds do 208 | begin 209 | Profile := Config.Profiles.FindById(ProfileId); 210 | if Assigned(Profile) then 211 | begin 212 | for Option in Profile.CompilerOptions do 213 | Result := Result + ' ' + Option; 214 | end; 215 | end; 216 | end; 217 | 218 | function TTestCompileCommand.Execute: Integer; 219 | var 220 | TestSourcePath, OutputDir: string; 221 | CompileCommand: string; 222 | DetectedFramework: TTestFramework; 223 | StatusDir, LogFile: string; 224 | SourceFiles, IncludeFiles: TStringList; 225 | begin 226 | Result := 0; 227 | 228 | TUtils.LogInfo('Compiling tests...'); 229 | 230 | // Check if test directory exists 231 | if not DirectoryExists('src/test/pascal') then 232 | begin 233 | TUtils.LogError('Test directory not found: src/test/pascal/'); 234 | TUtils.LogError('Run "pasbuild init" to create the test structure'); 235 | Result := 1; 236 | Exit; 237 | end; 238 | 239 | // Determine which framework to use 240 | if Config.TestConfig.Framework = tfAuto then 241 | DetectedFramework := DetectTestFramework 242 | else 243 | DetectedFramework := Config.TestConfig.Framework; 244 | 245 | // Log which framework is being used 246 | case DetectedFramework of 247 | tfFPCUnit: TUtils.LogInfo('Using test framework: FPCUnit'); 248 | tfFPTest: TUtils.LogInfo('Using test framework: FPTest'); 249 | end; 250 | 251 | // Create output directories 252 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 253 | 254 | // Main units directory (should already exist from compile goal) 255 | if not ForceDirectories(OutputDir + DirectorySeparator + 'units') then 256 | begin 257 | TUtils.LogError('Failed to create units directory'); 258 | Result := 1; 259 | Exit; 260 | end; 261 | 262 | // Test units directory (separate from main units) 263 | if not ForceDirectories(OutputDir + DirectorySeparator + 'test-units') then 264 | begin 265 | TUtils.LogError('Failed to create test-units directory'); 266 | Result := 1; 267 | Exit; 268 | end; 269 | 270 | // Check if test source file exists 271 | TestSourcePath := TUtils.NormalizePath('src/test/pascal/' + Config.TestConfig.TestSource); 272 | if not FileExists(TestSourcePath) then 273 | begin 274 | TUtils.LogError('Test source file not found: ' + TestSourcePath); 275 | Result := 1; 276 | Exit; 277 | end; 278 | 279 | // Create status directory and collect test source information 280 | try 281 | StatusDir := TUtils.CreateStatusDirectory('test-compile'); 282 | 283 | // Collect and write test source files list 284 | SourceFiles := TUtils.CollectSourceFiles(TUtils.NormalizePath('src/test/pascal')); 285 | try 286 | TUtils.LogInfo('Found ' + IntToStr(SourceFiles.Count) + ' test source file(s)'); 287 | TUtils.WriteListFile(StatusDir + DirectorySeparator + 'inputUnits.lst', SourceFiles); 288 | finally 289 | SourceFiles.Free; 290 | end; 291 | 292 | // Collect and write test include files list 293 | IncludeFiles := TUtils.CollectIncludeFiles(TUtils.NormalizePath('src/test/pascal')); 294 | try 295 | if IncludeFiles.Count > 0 then 296 | TUtils.LogInfo('Found ' + IntToStr(IncludeFiles.Count) + ' test include file(s)'); 297 | TUtils.WriteListFile(StatusDir + DirectorySeparator + 'inputIncludeFiles.lst', IncludeFiles); 298 | finally 299 | IncludeFiles.Free; 300 | end; 301 | 302 | LogFile := StatusDir + DirectorySeparator + 'fpc.log'; 303 | except 304 | on E: Exception do 305 | begin 306 | TUtils.LogWarning('Failed to create status directory: ' + E.Message); 307 | // Continue without status tracking 308 | StatusDir := ''; 309 | end; 310 | end; 311 | 312 | // Build compiler command for tests 313 | CompileCommand := BuildTestCompilerCommand(TestSourcePath); 314 | 315 | // Execute FPC (verbose mode shows full output, quiet mode logs to file) 316 | if FVerbose then 317 | begin 318 | // Verbose mode: Show full FPC output to console 319 | TUtils.LogInfo('Build command: ' + CompileCommand); 320 | WriteLn; 321 | Result := TUtils.ExecuteProcess(CompileCommand, True); 322 | WriteLn; 323 | if Result = 0 then 324 | TUtils.LogInfo('Test compilation successful') 325 | else 326 | TUtils.LogError('Test compilation failed with exit code: ' + IntToStr(Result)); 327 | end 328 | else 329 | begin 330 | // Quiet mode: Clean console output, full output logged to file 331 | if StatusDir <> '' then 332 | begin 333 | Result := TUtils.ExecuteProcessWithLog(CompileCommand, LogFile, True); 334 | if Result = 0 then 335 | TUtils.LogInfo('Test compilation successful (see ' + LogFile + ' for details)') 336 | else 337 | TUtils.LogError('Test compilation failed with exit code: ' + IntToStr(Result) + ' (see ' + LogFile + ' for details)'); 338 | end 339 | else 340 | begin 341 | // Fallback if status directory creation failed 342 | TUtils.LogInfo('Build command: ' + CompileCommand); 343 | WriteLn; 344 | Result := TUtils.ExecuteProcess(CompileCommand, True); 345 | WriteLn; 346 | if Result = 0 then 347 | TUtils.LogInfo('Test compilation successful') 348 | else 349 | TUtils.LogError('Test compilation failed with exit code: ' + IntToStr(Result)); 350 | end; 351 | end; 352 | end; 353 | 354 | { TTestCommand } 355 | 356 | function TTestCommand.GetName: string; 357 | begin 358 | Result := 'test'; 359 | end; 360 | 361 | function TTestCommand.GetDependencies: TBuildCommandList; 362 | begin 363 | Result := TBuildCommandList.Create(False); 364 | try 365 | // test depends on: test-compile (which depends on compile) 366 | Result.Add(TTestCompileCommand.Create(Config, ProfileIds)); 367 | except 368 | Result.Free; 369 | raise; 370 | end; 371 | end; 372 | 373 | function TTestCommand.BuildTestRunnerCommand(const ATestExecutable: string): string; 374 | var 375 | Option: string; 376 | begin 377 | // Start with executable path 378 | Result := ATestExecutable; 379 | 380 | // Add framework-specific options from configuration 381 | for Option in Config.TestConfig.FrameworkOptions do 382 | Result := Result + ' ' + Option; 383 | end; 384 | 385 | function TTestCommand.Execute: Integer; 386 | var 387 | OutputDir, TestExecutable: string; 388 | RunCommand: string; 389 | begin 390 | Result := 0; 391 | 392 | TUtils.LogInfo('Running tests...'); 393 | 394 | // Build path to test executable 395 | OutputDir := TUtils.NormalizePath(Config.BuildConfig.OutputDirectory); 396 | TestExecutable := OutputDir + DirectorySeparator + 'TestRunner' + TUtils.GetPlatformExecutableSuffix; 397 | 398 | // Check if test executable exists 399 | if not FileExists(TestExecutable) then 400 | begin 401 | TUtils.LogError('Test executable not found: ' + TestExecutable); 402 | TUtils.LogError('Run "pasbuild test-compile" first'); 403 | Result := 1; 404 | Exit; 405 | end; 406 | 407 | // Make executable on Unix systems 408 | {$IFDEF UNIX} 409 | TUtils.ExecuteProcess('chmod +x ' + TestExecutable, False); 410 | {$ENDIF} 411 | 412 | // Build test runner command with framework options 413 | RunCommand := BuildTestRunnerCommand(TestExecutable); 414 | 415 | TUtils.LogInfo('Execute command: ' + RunCommand); 416 | WriteLn; 417 | 418 | // Execute the test runner 419 | Result := TUtils.ExecuteProcess(RunCommand, True); 420 | 421 | WriteLn; 422 | if Result = 0 then 423 | TUtils.LogInfo('All tests passed') 424 | else 425 | TUtils.LogError('Tests failed with exit code: ' + IntToStr(Result)); 426 | end; 427 | 428 | end. 429 | -------------------------------------------------------------------------------- /src/main/pascal/PasBuild.Config.pas: -------------------------------------------------------------------------------- 1 | { 2 | This file is part of PasBuild. 3 | 4 | Copyright (c) 2025 Graeme Geldenhuys 5 | 6 | SPDX-License-Identifier: BSD-3-Clause 7 | 8 | See LICENSE file in the project root for full license terms. 9 | } 10 | 11 | unit PasBuild.Config; 12 | 13 | {$mode objfpc}{$H+} 14 | 15 | interface 16 | 17 | uses 18 | Classes, SysUtils, StrUtils, DOM, XMLRead, 19 | PasBuild.Types; 20 | 21 | type 22 | { Exception raised when project.xml is invalid } 23 | EProjectConfigError = class(Exception); 24 | 25 | { Configuration loader and validator } 26 | TConfigLoader = class 27 | private 28 | class function GetNodeText(AParent: TDOMNode; const ATagName: string; const ADefault: string = ''): string; 29 | class procedure ParseDefines(AParent: TDOMNode; ADefines: TStringList); 30 | class procedure ParseCompilerOptions(AParent: TDOMNode; AOptions: TStringList); 31 | class procedure ParseConditionalPaths(AParent: TDOMNode; const ATagName: string; APaths: TConditionalPathList); 32 | class procedure ParseBuildSection(ABuildNode: TDOMNode; AConfig: TProjectConfig); 33 | class procedure ParseTestSection(ATestNode: TDOMNode; AConfig: TProjectConfig); 34 | class procedure ParseResourcesSection(AResourcesNode: TDOMNode; AResourcesConfig: TResourcesConfig); 35 | class procedure ParseSourcePackageSection(ASourcePackageNode: TDOMNode; AConfig: TProjectConfig); 36 | class procedure ParseProfile(AProfileNode: TDOMNode; AProfile: TProfile); 37 | class procedure ParseProfiles(AProfilesNode: TDOMNode; AConfig: TProjectConfig); 38 | public 39 | class function LoadProjectXML(const AFilePath: string): TProjectConfig; 40 | class function ValidateConfig(AConfig: TProjectConfig): Boolean; 41 | class function ValidateSemanticVersion(const AVersion: string): Boolean; 42 | end; 43 | 44 | implementation 45 | 46 | uses 47 | RegExpr; 48 | 49 | { TConfigLoader } 50 | 51 | class function TConfigLoader.GetNodeText(AParent: TDOMNode; const ATagName: string; const ADefault: string): string; 52 | var 53 | Node: TDOMNode; 54 | begin 55 | Result := ADefault; 56 | if not Assigned(AParent) then 57 | Exit; 58 | 59 | Node := AParent.FindNode(ATagName); 60 | if Assigned(Node) and Assigned(Node.FirstChild) then 61 | Result := Trim(Node.TextContent); 62 | end; 63 | 64 | class procedure TConfigLoader.ParseDefines(AParent: TDOMNode; ADefines: TStringList); 65 | var 66 | DefinesNode, DefineNode: TDOMNode; 67 | I: Integer; 68 | begin 69 | if not Assigned(AParent) then 70 | Exit; 71 | 72 | DefinesNode := AParent.FindNode('defines'); 73 | if not Assigned(DefinesNode) then 74 | Exit; 75 | 76 | // Iterate through children 77 | for I := 0 to DefinesNode.ChildNodes.Count - 1 do 78 | begin 79 | DefineNode := DefinesNode.ChildNodes[I]; 80 | if (DefineNode.NodeType = ELEMENT_NODE) and (DefineNode.NodeName = 'define') then 81 | begin 82 | if Assigned(DefineNode.FirstChild) then 83 | ADefines.Add(Trim(DefineNode.TextContent)); 84 | end; 85 | end; 86 | end; 87 | 88 | class procedure TConfigLoader.ParseCompilerOptions(AParent: TDOMNode; AOptions: TStringList); 89 | var 90 | OptionsNode, OptionNode: TDOMNode; 91 | I: Integer; 92 | begin 93 | if not Assigned(AParent) then 94 | Exit; 95 | 96 | OptionsNode := AParent.FindNode('compilerOptions'); 97 | if not Assigned(OptionsNode) then 98 | Exit; 99 | 100 | // Iterate through