From bfbd02258f0920dbdd3347ae88da88925b851383 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Thu, 9 Apr 2026 13:02:21 +0100 Subject: [PATCH 1/2] feature: ini config closes #270 --- .gitignore | 4 +- composer.json | 5 + composer.lock | 5 +- src/Build.php | 29 ++- src/BuildRunner.php | 49 ++-- src/Cli/RunCommand.php | 6 +- src/Configuration/Manifest.php | 157 +++++++++++- src/ConfigurationParseException.php | 4 + src/Task.php | 5 +- src/TaskList.php | 2 +- test/phpunit/Helper/Ini/build.dev.ini | 6 + test/phpunit/Helper/Ini/build.ini | 6 + .../Ini/build.single-property-override.ini | 2 + test/phpunit/ManifestTest.php | 230 ++++++++++++++++++ test/phpunit/RunCommandTest.php | 27 ++ 15 files changed, 493 insertions(+), 44 deletions(-) create mode 100644 src/ConfigurationParseException.php create mode 100644 test/phpunit/Helper/Ini/build.dev.ini create mode 100644 test/phpunit/Helper/Ini/build.ini create mode 100644 test/phpunit/Helper/Ini/build.single-property-override.ini diff --git a/.gitignore b/.gitignore index ea2623b..ab800b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /.idea /vendor /*.phar -.phpunit* \ No newline at end of file +.phpunit* +# Local wiki symlink +docs diff --git a/composer.json b/composer.json index a669019..ad44198 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,11 @@ "Gt\\Build\\Test\\": "./test/phpunit" } }, + "config": { + "platform": { + "php": "8.2.0" + } + }, "bin": [ "bin/build" diff --git a/composer.lock b/composer.lock index a0b35d5..3755755 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bbc18ac17beadd109bab0374beb98605", + "content-hash": "4c2917751f2364a3704de3cdded301ba", "packages": [ { "name": "composer/semver", @@ -3249,5 +3249,8 @@ "ext-json": "*" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, "plugin-api-version": "2.9.0" } diff --git a/src/Build.php b/src/Build.php index fb437fb..82607d7 100644 --- a/src/Build.php +++ b/src/Build.php @@ -5,12 +5,14 @@ class Build { protected TaskList $taskList; + protected string $workingDirectory; public function __construct( string $jsonFilePath, string $workingDirectory, ?string $mode = null ) { + $this->workingDirectory = $workingDirectory; $this->taskList = new TaskList( $jsonFilePath, $workingDirectory, @@ -25,18 +27,25 @@ public function __construct( */ public function check(?array &$errors = null):int { $count = 0; + $previousCwd = getcwd(); + chdir($this->workingDirectory); - foreach($this->taskList as $pathMatch => $task) { - $absolutePathMatch = implode(DIRECTORY_SEPARATOR, [ - getcwd(), - $pathMatch, - ]); - $fileList = Glob::glob($absolutePathMatch); - if(!empty($fileList)) { - $task->check($errors); - } + try { + foreach($this->taskList as $pathMatch => $task) { + $absolutePathMatch = implode(DIRECTORY_SEPARATOR, [ + $this->workingDirectory, + $pathMatch, + ]); + $fileList = Glob::glob($absolutePathMatch); + if(!empty($fileList)) { + $task->check($errors); + } - $count++; + $count++; + } + } + finally { + chdir($previousCwd); } return $count; diff --git a/src/BuildRunner.php b/src/BuildRunner.php index 1237510..4fe999d 100644 --- a/src/BuildRunner.php +++ b/src/BuildRunner.php @@ -22,7 +22,7 @@ public function __construct(?string $path = null, ?Stream $stream = null) { } $this->defaultPath = implode(DIRECTORY_SEPARATOR, [ getcwd(), - "build.json", + "build.ini", ]); $this->workingDirectory = $path; $this->stream = $stream; @@ -68,32 +68,49 @@ protected function formatWorkingDirectory():string { /** @SuppressWarnings(PHPMD.ExitExpression) */ protected function getJsonPath(string $workingDirectory):string { - $jsonPath = $workingDirectory; - if(is_dir($jsonPath)) { - $jsonPath .= DIRECTORY_SEPARATOR; - $jsonPath .= "build.json"; - } + $jsonPath = $this->resolveConfigPath($workingDirectory) + ?? $this->resolveConfigPath($this->defaultPath); - if(!is_file($jsonPath)) { - $jsonPath = $this->defaultPath; - } - if(!is_file($jsonPath)) { - $whichPath = $jsonPath === $this->defaultPath - ? "default" - : "user"; + if(is_null($jsonPath)) { + $whichPath = $this->defaultPath === $workingDirectory + ? "user" + : "default"; + $errorString = "No build config found. Trying $whichPath path: $this->defaultPath"; $this->stream->writeLine( - "No build config found. Trying $whichPath path: $jsonPath", + $errorString, Stream::ERROR ); // TODO: Dynamic exit code https://github.com/PhpGt/Cli/issues/13 // phpcs:ignore - exit(1); + throw new BuildException($errorString); } return $jsonPath; } + protected function resolveConfigPath(string $path):?string { + if(is_dir($path)) { + $iniPath = $path . DIRECTORY_SEPARATOR . "build.ini"; + if(is_file($iniPath)) { + return $iniPath; + } + + $jsonPath = $path . DIRECTORY_SEPARATOR . "build.json"; + if(is_file($jsonPath)) { + return $jsonPath; + } + + return null; + } + + if(is_file($path)) { + return $path; + } + + return null; + } + /** * @SuppressWarnings(PHPMD.ExitExpression) * @param array $errors @@ -110,7 +127,7 @@ protected function checkRequirements( $workingDirectory, $mode, ); - } catch(JsonParseException $exception) { + } catch(ConfigurationParseException $exception) { $this->stream->writeLine("Syntax error in $jsonPath", Stream::ERROR); // TODO: Dynamic exit code https://github.com/PhpGt/Cli/issues/13 // phpcs:ignore diff --git a/src/Cli/RunCommand.php b/src/Cli/RunCommand.php index ee1dd21..796637a 100644 --- a/src/Cli/RunCommand.php +++ b/src/Cli/RunCommand.php @@ -9,7 +9,11 @@ class RunCommand extends Command { public function run(?ArgumentValueList $arguments = null):int { - $buildRunner = new BuildRunner(getcwd(), $this->stream); + $path = getcwd(); + if($arguments->contains("config")) { + $path = (string)$arguments->get("config"); + } + $buildRunner = new BuildRunner($path, $this->stream); if($arguments->contains("default")) { $buildRunner->setDefaultPath($arguments->get("default")); } diff --git a/src/Configuration/Manifest.php b/src/Configuration/Manifest.php index b8cdcea..438bd87 100644 --- a/src/Configuration/Manifest.php +++ b/src/Configuration/Manifest.php @@ -1,9 +1,10 @@ parseConfigurationFile($configFilePath); if($mode) { $modeJsonFilePath = substr( - $jsonFilePath, + $configFilePath, 0, - -strlen(".json"), + -strlen(pathinfo($configFilePath, PATHINFO_EXTENSION)) - 1, ); - $modeJsonFilePath .= ".$mode.json"; + $modeJsonFilePath .= ".$mode." . pathinfo($configFilePath, PATHINFO_EXTENSION); if(!is_file($modeJsonFilePath)) { throw new MissingBuildFileException($modeJsonFilePath); } - $modeJson = json_decode(file_get_contents($modeJsonFilePath)); + $modeJson = $this->parseConfigurationFile($modeJsonFilePath); // For legacy reasons, stdClass is used to represent the block details. // This code might look weird, but it remains backwards compatible until an OOP // refactoring is made. @@ -86,6 +84,141 @@ protected function setIteratorKey():void { $this->iteratorKey = $keys[$this->iteratorIndex] ?? null; } + private function parseConfigurationFile(string $configFilePath):object { + return match(strtolower(pathinfo($configFilePath, PATHINFO_EXTENSION))) { + "ini" => $this->parseIniFile($configFilePath), + "json" => $this->parseJsonFile($configFilePath), + default => $this->parseJsonFile($configFilePath), + }; + } + + private function parseJsonFile(string $configFilePath):object { + $json = json_decode(file_get_contents($configFilePath)); + if(is_null($json)) { + throw new ConfigurationParseException(json_last_error_msg()); + } + + if(is_array($json)) { + return (object)$json; + } + + return $json; + } + + private function parseIniFile(string $configFilePath):object { + $ini = @parse_ini_file($configFilePath, true, INI_SCANNER_RAW); + if($ini === false) { + throw new ConfigurationParseException("Syntax error"); + } + + $manifest = new stdClass(); + foreach($ini as $glob => $details) { + $taskBlock = new stdClass(); + + if(isset($details["name"])) { + $taskBlock->name = $details["name"]; + } + + if(isset($details["require"])) { + $taskBlock->require = $this->parseRequireString($details["require"]); + } + + if(!isset($details["execute"])) { + throw new MissingConfigurationKeyException("execute"); + } + + $taskBlock->execute = $this->parseExecuteString($details["execute"]); + $manifest->$glob = $taskBlock; + } + + return $manifest; + } + + private function parseRequireString(string $requireString):object { + $require = new stdClass(); + foreach(explode(",", $requireString) as $requirementDefinition) { + $requirementDefinition = trim($requirementDefinition); + if($requirementDefinition === "") { + continue; + } + + $requirementParts = preg_split("/\s+/", $requirementDefinition, 2); + $command = $requirementParts[0]; + $version = trim($requirementParts[1] ?? "*"); + if($version === "") { + $version = "*"; + } + + $require->$command = $version; + } + + return $require; + } + + private function parseExecuteString(string $executeString):object { + if(strpbrk($executeString, ";#") !== false || preg_match("/[\r\n]/", $executeString)) { + throw new ConfigurationParseException( + "Forbidden character in execute command" + ); + } + + $tokens = []; + $currentToken = ""; + $quote = null; + $tokenInProgress = false; + $length = strlen($executeString); + + for($i = 0; $i < $length; $i++) { + $char = $executeString[$i]; + + if($quote !== null) { + if($char === $quote) { + $quote = null; + } + else { + $currentToken .= $char; + } + $tokenInProgress = true; + continue; + } + + if($char === "'" || $char === '"') { + $quote = $char; + $tokenInProgress = true; + continue; + } + + if(ctype_space($char)) { + if($tokenInProgress) { + $tokens []= $currentToken; + $currentToken = ""; + $tokenInProgress = false; + } + continue; + } + + $currentToken .= $char; + $tokenInProgress = true; + } + + if($quote !== null) { + throw new ConfigurationParseException("Unterminated quote in execute command"); + } + + if($tokenInProgress) { + $tokens []= $currentToken; + } + + if(empty($tokens)) { + throw new MissingConfigurationKeyException("execute.command"); + } + + $execute = new stdClass(); + $execute->command = array_shift($tokens); + $execute->arguments = $tokens; + return $execute; + } + private function recursiveMerge(object $json, object $diff):object { foreach($diff as $key => $value) { if(property_exists($json, $key)) { diff --git a/src/ConfigurationParseException.php b/src/ConfigurationParseException.php new file mode 100644 index 0000000..de9bd80 --- /dev/null +++ b/src/ConfigurationParseException.php @@ -0,0 +1,4 @@ +glob = $taskBlock->getGlob(); - $this->basePath = getcwd(); + $this->basePath = $basePath ?? getcwd(); $this->absolutePath = implode(DIRECTORY_SEPARATOR, [ $this->basePath, $this->glob, diff --git a/src/TaskList.php b/src/TaskList.php index 26424b9..45818dc 100644 --- a/src/TaskList.php +++ b/src/TaskList.php @@ -24,7 +24,7 @@ public function __construct( $specification = new Manifest($jsonFilePath, $mode); foreach($specification as $glob => $taskBlock) { - $this->taskList[$glob] = new Task($taskBlock); + $this->taskList[$glob] = new Task($taskBlock, $baseDir); } } diff --git a/test/phpunit/Helper/Ini/build.dev.ini b/test/phpunit/Helper/Ini/build.dev.ini new file mode 100644 index 0000000..ca810c6 --- /dev/null +++ b/test/phpunit/Helper/Ini/build.dev.ini @@ -0,0 +1,6 @@ +[script/**/*.es6] +execute=./node_modules/.bin/esbuild "script/dev entry.es6" --bundle --outfile=www/script.dev.js + +[page/**/*.php] +require=vendor/bin/sitemap ^1.0 +execute=php vendor/bin/sitemap src/page www/sitemap.xml diff --git a/test/phpunit/Helper/Ini/build.ini b/test/phpunit/Helper/Ini/build.ini new file mode 100644 index 0000000..7a329fa --- /dev/null +++ b/test/phpunit/Helper/Ini/build.ini @@ -0,0 +1,6 @@ +[script/**/*.es6] +require=node_modules/.bin/esbuild >=1.2.3,babel,esbuild ^0.17 +execute=./node_modules/.bin/esbuild "script/script file.es6" --bundle --sourcemap --outfile=www/script.js --loader:.es6=js --target=chrome105,firefox105,edge105,safari15 + +[style/**/*.scss] +execute=sass ./style/style.scss ./www/style.css diff --git a/test/phpunit/Helper/Ini/build.single-property-override.ini b/test/phpunit/Helper/Ini/build.single-property-override.ini new file mode 100644 index 0000000..57bbd64 --- /dev/null +++ b/test/phpunit/Helper/Ini/build.single-property-override.ini @@ -0,0 +1,2 @@ +[script/**/*.es6] +execute=./node_modules/.bin/esbuild script/script.es6 --bundle --minify diff --git a/test/phpunit/ManifestTest.php b/test/phpunit/ManifestTest.php index facc3e6..de63e06 100644 --- a/test/phpunit/ManifestTest.php +++ b/test/phpunit/ManifestTest.php @@ -1,11 +1,241 @@ getExecuteBlock()->arguments, + ); + self::assertSame( + "./node_modules/.bin/esbuild", + $taskBlocks["script/**/*.es6"]->getExecuteBlock()->command, + ); + self::assertSame( + [ + "node_modules/.bin/esbuild >=1.2.3", + "babel *", + "esbuild ^0.17", + ], + array_map( + static fn($requirement) => (string)$requirement, + $taskBlocks["script/**/*.es6"]->getRequireBlock()->getRequirementList(), + ), + ); + self::assertNull($taskBlocks["style/**/*.scss"]->getRequireBlock()); + self::assertSame( + ["./style/style.scss", "./www/style.css"], + $taskBlocks["style/**/*.scss"]->getExecuteBlock()->arguments, + ); + } + + public function testIterator_iniMode():void { + $iniFile = "test/phpunit/Helper/Ini/build.ini"; + $sut = new Manifest($iniFile, "dev"); + $taskBlocks = iterator_to_array($sut); + + self::assertCount(3, $taskBlocks); + self::assertSame( + ["./node_modules/.bin/esbuild", "script/dev entry.es6", "--bundle", "--outfile=www/script.dev.js"], + array_merge( + [$taskBlocks["script/**/*.es6"]->getExecuteBlock()->command], + $taskBlocks["script/**/*.es6"]->getExecuteBlock()->arguments, + ), + ); + self::assertSame( + ["vendor/bin/sitemap ^1.0"], + array_map( + static fn($requirement) => (string)$requirement, + $taskBlocks["page/**/*.php"]->getRequireBlock()->getRequirementList(), + ), + ); + } + + public function testIterator_iniModeOnlyOverridesSingleProperty():void { + $iniFile = "test/phpunit/Helper/Ini/build.ini"; + $sut = new Manifest($iniFile, "single-property-override"); + $taskBlocks = iterator_to_array($sut); + + self::assertSame( + ["./node_modules/.bin/esbuild", "script/script.es6", "--bundle", "--minify"], + array_merge( + [$taskBlocks["script/**/*.es6"]->getExecuteBlock()->command], + $taskBlocks["script/**/*.es6"]->getExecuteBlock()->arguments, + ), + ); + self::assertSame( + [ + "node_modules/.bin/esbuild >=1.2.3", + "babel *", + "esbuild ^0.17", + ], + array_map( + static fn($requirement) => (string)$requirement, + $taskBlocks["script/**/*.es6"]->getRequireBlock()->getRequirementList(), + ), + ); + } + + public function testIterator_iniNameAndDefaultRequirementVersion():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js]", + "name=Bundle JS", + "require=node_modules/.bin/esbuild", + "execute=./node_modules/.bin/esbuild asset/app.js --bundle", + "", + ])); + + try { + $sut = new Manifest($iniFilePath); + $taskBlocks = iterator_to_array($sut); + + self::assertSame("Bundle JS", $taskBlocks["asset/**/*.js"]->getName()); + self::assertSame( + ["node_modules/.bin/esbuild *"], + array_map( + static fn($requirement) => (string)$requirement, + $taskBlocks["asset/**/*.js"]->getRequireBlock()->getRequirementList(), + ), + ); + } + finally { + unlink($iniFilePath); + } + } + + public function testIterator_iniModeMissingFileThrows():void { + $this->expectException(MissingBuildFileException::class); + new Manifest("test/phpunit/Helper/Ini/build.ini", "missing"); + } + + public function testIterator_iniMissingExecuteThrows():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js]", + "require=node_modules/.bin/esbuild ^1", + "", + ])); + + try { + $this->expectException(MissingConfigurationKeyException::class); + new Manifest($iniFilePath); + } + finally { + unlink($iniFilePath); + } + } + + public function testIterator_iniRejectsForbiddenExecuteCharacters():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js]", + "execute=./node_modules/.bin/esbuild asset/app.js --bundle # forbidden", + "", + ])); + + try { + $this->expectException(ConfigurationParseException::class); + new Manifest($iniFilePath); + } + finally { + unlink($iniFilePath); + } + } + + public function testIterator_iniRejectsUnterminatedQuotedExecuteArgument():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js]", + "execute=./node_modules/.bin/esbuild \"asset/app.js --bundle", + "", + ])); + + try { + $this->expectException(ConfigurationParseException::class); + new Manifest($iniFilePath); + } + finally { + unlink($iniFilePath); + } + } + + public function testIterator_iniIgnoresEmptyRequirementsAndDefaultsBlankVersion():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js]", + "require=node_modules/.bin/esbuild , ,vendor/bin/tool ^2", + "execute=./node_modules/.bin/esbuild asset/app.js --bundle", + "", + ])); + + try { + $sut = new Manifest($iniFilePath); + $taskBlocks = iterator_to_array($sut); + + self::assertSame( + [ + "node_modules/.bin/esbuild *", + "vendor/bin/tool ^2", + ], + array_map( + static fn($requirement) => (string)$requirement, + $taskBlocks["asset/**/*.js"]->getRequireBlock()->getRequirementList(), + ), + ); + } + finally { + unlink($iniFilePath); + } + } + + public function testIterator_iniRejectsEmptyExecuteCommand():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js]", + "execute= ", + "", + ])); + + try { + $this->expectException(MissingConfigurationKeyException::class); + new Manifest($iniFilePath); + } + finally { + unlink($iniFilePath); + } + } + + public function testIterator_iniSyntaxErrorThrowsConfigurationParseException():void { + $iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true) . ".ini"; + file_put_contents($iniFilePath, implode(PHP_EOL, [ + "[asset/**/*.js", + "execute=./node_modules/.bin/esbuild asset/app.js --bundle", + "", + ])); + + try { + $this->expectException(ConfigurationParseException::class); + new Manifest($iniFilePath); + } + finally { + unlink($iniFilePath); + } + } + public function testIterator():void { $jsonFile = "test/phpunit/Helper/Json/build.json"; $jsonObj = json_decode(file_get_contents($jsonFile), true); diff --git a/test/phpunit/RunCommandTest.php b/test/phpunit/RunCommandTest.php index c2545fc..2cde20b 100644 --- a/test/phpunit/RunCommandTest.php +++ b/test/phpunit/RunCommandTest.php @@ -1,11 +1,38 @@ setStream(); + $arguments = new ArgumentValueList(); + $exitCode = $sut->run($arguments); + + self::assertSame(0, $exitCode); + } + catch(BuildException $exception) {} + finally { + chdir($cwd); + unlink($tempDir . DIRECTORY_SEPARATOR . "build.ini"); + rmdir($tempDir); + } + + self::assertNull($exception); + } + public function testRunWithoutModeDoesNotThrow():void { $cwd = getcwd(); $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true); From 191a2aefb078763ce66fb070ddb4e7d96a5b6e82 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Thu, 9 Apr 2026 13:34:30 +0100 Subject: [PATCH 2/2] test: improve coverage --- composer.json | 13 ++++ src/Configuration/Manifest.php | 114 ++++++++++++++++++++++++--------- 2 files changed, 96 insertions(+), 31 deletions(-) diff --git a/composer.json b/composer.json index ad44198..b862e62 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,19 @@ "bin/build" ], + "scripts": { + "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", + "phpstan": "vendor/bin/phpstan analyse --level 6 src", + "phpcs": "vendor/bin/phpcs src --standard=phpcs.xml", + "phpmd": "vendor/bin/phpmd src/ text phpmd.xml", + "test": [ + "@phpunit", + "@phpstan", + "@phpcs", + "@phpmd" + ] + }, + "funding": [ { "type": "github", diff --git a/src/Configuration/Manifest.php b/src/Configuration/Manifest.php index 438bd87..50bc917 100644 --- a/src/Configuration/Manifest.php +++ b/src/Configuration/Manifest.php @@ -106,7 +106,11 @@ private function parseJsonFile(string $configFilePath):object { } private function parseIniFile(string $configFilePath):object { - $ini = @parse_ini_file($configFilePath, true, INI_SCANNER_RAW); + set_error_handler(static function():bool { + return true; + }); + $ini = parse_ini_file($configFilePath, true, INI_SCANNER_RAW); + restore_error_handler(); if($ini === false) { throw new ConfigurationParseException("Syntax error"); } @@ -169,36 +173,13 @@ private function parseExecuteString(string $executeString):object { $length = strlen($executeString); for($i = 0; $i < $length; $i++) { - $char = $executeString[$i]; - - if($quote !== null) { - if($char === $quote) { - $quote = null; - } - else { - $currentToken .= $char; - } - $tokenInProgress = true; - continue; - } - - if($char === "'" || $char === '"') { - $quote = $char; - $tokenInProgress = true; - continue; - } - - if(ctype_space($char)) { - if($tokenInProgress) { - $tokens []= $currentToken; - $currentToken = ""; - $tokenInProgress = false; - } - continue; - } - - $currentToken .= $char; - $tokenInProgress = true; + $this->consumeExecuteCharacter( + $executeString[$i], + $tokens, + $currentToken, + $quote, + $tokenInProgress, + ); } if($quote !== null) { @@ -219,6 +200,77 @@ private function parseExecuteString(string $executeString):object { return $execute; } + /** + * @param array $tokens + * @param string|null $quote + */ + private function consumeExecuteCharacter( + string $char, + array &$tokens, + string &$currentToken, + ?string &$quote, + bool &$tokenInProgress, + ):void { + if($quote !== null) { + $this->consumeQuotedExecuteCharacter( + $char, + $currentToken, + $quote, + $tokenInProgress, + ); + return; + } + + if($char === "'" || $char === '"') { + $quote = $char; + $tokenInProgress = true; + return; + } + + if(ctype_space($char)) { + $this->finaliseExecuteToken( + $tokens, + $currentToken, + $tokenInProgress, + ); + return; + } + + $currentToken .= $char; + $tokenInProgress = true; + } + + private function consumeQuotedExecuteCharacter( + string $char, + string &$currentToken, + ?string &$quote, + bool &$tokenInProgress, + ):void { + if($char === $quote) { + $quote = null; + } + else { + $currentToken .= $char; + } + + $tokenInProgress = true; + } + + /** @param array $tokens */ + private function finaliseExecuteToken( + array &$tokens, + string &$currentToken, + bool &$tokenInProgress, + ):void { + if(!$tokenInProgress) { + return; + } + + $tokens []= $currentToken; + $currentToken = ""; + $tokenInProgress = false; + } + private function recursiveMerge(object $json, object $diff):object { foreach($diff as $key => $value) { if(property_exists($json, $key)) {