From 81c2d2a8226bea5f4045f61c5f75725727f03df3 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 11 Mar 2026 19:19:21 +0000 Subject: [PATCH 1/6] feature: implement customisable expressions for #11 --- README.md | 4 + composer.json | 3 +- composer.lock | 209 ++++++++++------------------- src/Cli/RunCommand.php | 2 +- src/Job.php | 95 +++++++++++-- src/JobRepository.php | 11 +- src/Runner.php | 48 ++----- src/RunnerFactory.php | 2 +- test/phpunit/JobRepositoryTest.php | 8 +- test/phpunit/JobTest.php | 49 ++++++- test/phpunit/RunnerFactoryTest.php | 6 +- test/phpunit/RunnerTest.php | 14 ++ 12 files changed, 247 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index 3e60bd1..9cd23b5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Start the Runner: `vendor/bin/cron`. If you're using [WebEngine](https://php.gt/webengine), the Cron Runner is automatically started for you by running `gt run`. +## Examples + +There is an [example](example/README.md) directory with numbered scripts that can be run directly with `php`. Each one embeds its own crontab string so the schedule and output stay together. + # Proudly sponsored by [JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/) diff --git a/composer.json b/composer.json index aa93378..f8e2e30 100644 --- a/composer.json +++ b/composer.json @@ -4,8 +4,7 @@ "require": { "php": ">=8.1", - "phpgt/cli": "1.*", - "dragonmantank/cron-expression": "^2.2" + "phpgt/cli": "1.*" }, "require-dev": { "phpstan/phpstan": "^1.10", diff --git a/composer.lock b/composer.lock index a7e09ff..57bba4d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,95 +4,33 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "21924b3fa55478f5a1ce1170b712a626", + "content-hash": "e1552c5d5d8058c04bc2fcc9b38861bc", "packages": [ - { - "name": "dragonmantank/cron-expression", - "version": "v2.3.1", - "source": { - "type": "git", - "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2", - "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2", - "shasum": "" - }, - "require": { - "php": "^7.0|^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - } - }, - "autoload": { - "psr-4": { - "Cron\\": "src/Cron/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Chris Tankersley", - "email": "chris@ctankersley.com", - "homepage": "https://github.com/dragonmantank" - } - ], - "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", - "keywords": [ - "cron", - "schedule" - ], - "support": { - "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v2.3.1" - }, - "funding": [ - { - "url": "https://github.com/dragonmantank", - "type": "github" - } - ], - "time": "2020-10-13T00:52:37+00:00" - }, { "name": "phpgt/cli", - "version": "v1.3.4", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/PhpGt/Cli.git", - "reference": "71deb9cdc5a3ea8bfb665faa29739badbf61e9da" + "reference": "42aeef24ab9789907358002fdcc37cb3a2e26b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/Cli/zipball/71deb9cdc5a3ea8bfb665faa29739badbf61e9da", - "reference": "71deb9cdc5a3ea8bfb665faa29739badbf61e9da", + "url": "https://api.github.com/repos/PhpGt/Cli/zipball/42aeef24ab9789907358002fdcc37cb3a2e26b4b", + "reference": "42aeef24ab9789907358002fdcc37cb3a2e26b4b", "shasum": "" }, "require": { "ext-json": "*", "ext-readline": "*", - "php": ">=8.0", - "phpgt/daemon": "^v1.1" + "php": ">=8.2", + "phpgt/daemon": "^1.1.3" }, "require-dev": { - "phpstan/phpstan": "^v1.8", - "phpunit/phpunit": "^v9.5" + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7" }, "type": "library", "autoload": { @@ -119,7 +57,7 @@ ], "support": { "issues": "https://github.com/PhpGt/Cli/issues", - "source": "https://github.com/PhpGt/Cli/tree/v1.3.4" + "source": "https://github.com/PhpGt/Cli/tree/v1.3.5" }, "funding": [ { @@ -127,28 +65,31 @@ "type": "github" } ], - "time": "2023-09-18T10:06:17+00:00" + "time": "2024-05-08T17:45:44+00:00" }, { "name": "phpgt/daemon", - "version": "v1.1.2", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/PhpGt/Daemon.git", - "reference": "6490df99a22818149f30e3af408002ea7f73e035" + "url": "https://github.com/phpgt/Daemon.git", + "reference": "413e16b54de6e1fd5c2b646b485f88a86dfedd9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/Daemon/zipball/6490df99a22818149f30e3af408002ea7f73e035", - "reference": "6490df99a22818149f30e3af408002ea7f73e035", + "url": "https://api.github.com/repos/phpgt/Daemon/zipball/413e16b54de6e1fd5c2b646b485f88a86dfedd9a", + "reference": "413e16b54de6e1fd5c2b646b485f88a86dfedd9a", "shasum": "" }, "require": { - "php": ">=7.4" + "ext-pcntl": "*", + "php": ">=8.1" }, "require-dev": { - "phpstan/phpstan": ">=0.12.42", - "phpunit/phpunit": "9.*" + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^v1.10", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7" }, "type": "library", "autoload": { @@ -159,16 +100,16 @@ "notification-url": "https://packagist.org/downloads/", "description": "Background script execution with cross-platform compatible streaming.", "support": { - "issues": "https://github.com/PhpGt/Daemon/issues", - "source": "https://github.com/PhpGt/Daemon/tree/v1.1.2" + "issues": "https://github.com/phpgt/Daemon/issues", + "source": "https://github.com/phpgt/Daemon/tree/v1.1.5" }, "funding": [ { - "url": "https://github.com/phpgt", + "url": "https://github.com/sponsors/PhpGt", "type": "github" } ], - "time": "2021-02-02T17:33:16+00:00" + "time": "2026-03-11T14:11:10+00:00" } ], "packages-dev": [ @@ -2319,34 +2260,34 @@ }, { "name": "symfony/config", - "version": "v6.4.34", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9" + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/ce9cb0c0d281aaf188b802d4968e42bfb60701e9", - "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9", + "url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5", + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^7.1|^8.0", "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "symfony/finder": "<5.4", + "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2374,7 +2315,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.34" + "source": "https://github.com/symfony/config/tree/v7.4.7" }, "funding": [ { @@ -2394,44 +2335,43 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:34:50+00:00" + "time": "2026-03-06T10:41:14+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.35", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "d95712d0e9446b9f244b64811ffb6af7b7434213" + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/d95712d0e9446b9f244b64811ffb6af7b7434213", - "reference": "d95712d0e9446b9f244b64811ffb6af7b7434213", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4.20|^7.2.5" + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" }, "conflict": { "ext-psr": "<1.1|>=2", - "symfony/config": "<6.1", - "symfony/finder": "<5.4", - "symfony/proxy-manager-bridge": "<6.3", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "psr/container-implementation": "1.1|2.0", "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.1|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2459,7 +2399,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.35" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.7" }, "funding": [ { @@ -2479,7 +2419,7 @@ "type": "tidelift" } ], - "time": "2026-02-26T12:16:01+00:00" + "time": "2026-03-03T07:48:48+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2550,25 +2490,25 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.34", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2596,7 +2536,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.34" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -2616,7 +2556,7 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:51:06+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2875,26 +2815,25 @@ }, { "name": "symfony/var-exporter", - "version": "v6.4.26", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc" + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/466fcac5fa2e871f83d31173f80e9c2684743bfc", - "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -2932,7 +2871,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.26" + "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" }, "funding": [ { @@ -2952,7 +2891,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-11-05T18:53:00+00:00" }, { "name": "theseer/tokenizer", diff --git a/src/Cli/RunCommand.php b/src/Cli/RunCommand.php index 0eed591..aa1b9f3 100644 --- a/src/Cli/RunCommand.php +++ b/src/Cli/RunCommand.php @@ -22,7 +22,7 @@ public function run(?ArgumentValueList $arguments = null):void { ]); try { - $runner = RunnerFactory::createForProject( + $runner = (new RunnerFactory())->createForProject( getcwd(), $filename ); diff --git a/src/Job.php b/src/Job.php index 96f7d08..1732e1c 100644 --- a/src/Job.php +++ b/src/Job.php @@ -1,18 +1,27 @@ expression = $expression; $this->command = $command; $this->hasRun = false; + $this->scriptOutputMode = $scriptOutputMode; + $this->stdout = ""; + $this->stderr = ""; } public function isDue(?DateTime $now = null):bool { @@ -34,8 +43,18 @@ public function getCommand():string { return $this->command; } + public function getStdout():string { + return $this->stdout; + } + + public function getStderr():string { + return $this->stderr; + } + public function run():void { $this->hasRun = true; + $this->stdout = ""; + $this->stderr = ""; if($this->isFunction()) { $this->executeFunction(); @@ -79,7 +98,7 @@ protected function executeFunction():void { $bracketPos ); $argsString = trim($argsString, " ();"); - $args = str_getcsv($argsString); + $args = str_getcsv($argsString, ",", "\"", "\\"); $command = substr( $command, @@ -99,11 +118,7 @@ protected function executeFunction():void { protected function executeScript():void { $command = $this->resolveScriptCommand(); - $descriptor = [ - 0 => ["pipe", "r"], - 1 => ["pipe", "w"], - 2 => ["pipe", "w"], - ]; + $descriptor = $this->createScriptDescriptor(); $pipes = []; $proc = proc_open( @@ -124,6 +139,10 @@ protected function executeScript():void { } }while($status["running"]); + if($proc) { + $this->captureProcessOutput($pipes); + } + if($status["exitcode"] > 0) { throw new ScriptExecutionException( $this->command @@ -131,10 +150,66 @@ protected function executeScript():void { } if($proc) { + $this->closePipes($pipes); proc_close($proc); } } + /** @return array */ + protected function createScriptDescriptor():array { + $stdin = ["pipe", "r"]; + + return match($this->scriptOutputMode) { + ScriptOutputMode::INHERIT => [ + 0 => $stdin, + 1 => ["file", "php://stdout", "w"], + 2 => ["file", "php://stderr", "w"], + ], + ScriptOutputMode::CAPTURE => [ + 0 => $stdin, + 1 => ["pipe", "w"], + 2 => ["pipe", "w"], + ], + default => [ + 0 => $stdin, + 1 => ["file", $this->nullDevice(), "w"], + 2 => ["file", $this->nullDevice(), "w"], + ], + }; + } + + /** @param array $pipes */ + protected function captureProcessOutput(array $pipes):void { + if($this->scriptOutputMode !== ScriptOutputMode::CAPTURE) { + return; + } + + if(isset($pipes[1]) && is_resource($pipes[1])) { + $this->stdout = stream_get_contents($pipes[1]) ?: ""; + } + + if(isset($pipes[2]) && is_resource($pipes[2])) { + $this->stderr = stream_get_contents($pipes[2]) ?: ""; + } + } + + /** @param array $pipes */ + protected function closePipes(array $pipes):void { + foreach($pipes as $pipe) { + if(is_resource($pipe)) { + fclose($pipe); + } + } + } + + protected function nullDevice():string { + if(PHP_OS_FAMILY === "Windows") { + return "NUL"; + } + + return "/dev/null"; + } + protected function resolveScriptCommand():string { $scriptParts = $this->parseScriptCommand($this->command); if(is_null($scriptParts)) { diff --git a/src/JobRepository.php b/src/JobRepository.php index ee2da9d..7117422 100644 --- a/src/JobRepository.php +++ b/src/JobRepository.php @@ -1,10 +1,13 @@ scriptOutputMode); } } diff --git a/src/Runner.php b/src/Runner.php index b22b710..abab9e2 100644 --- a/src/Runner.php +++ b/src/Runner.php @@ -1,7 +1,6 @@ queue = call_user_func( [$queueRepository, "createAtTime"], $now ?? new DateTime() ); - - $this->numJobs = 0; - - foreach(explode("\n", $contents) as $line) { - $line = trim($line); - if(strlen($line) === 0) { - continue; - } - - if($line[0] === "#") { - continue; - } - - preg_match( - "/(?P\S+\s\S+\s\S+\s\S+\s\S+)\s(?P.+)/", - $line, - $matches - ); - - $crontab = $matches["crontab"] ?? null; - $command = $matches["command"] ?? null; - - $crontab = trim($crontab); - $command = trim($command); - - try { - $job = call_user_func( - [$jobRepository, "create"], - CronExpression::factory($crontab), - $command - ); - $this->queue->add($job); - } - catch(InvalidArgumentException $exception) { - throw new ParseException("Invalid syntax: $line"); - } - - $this->numJobs++; - } + $crontabParser ??= new CrontabParser($expressionFactory); + $this->numJobs = $crontabParser->parseIntoQueue( + $contents, + $this->queue, + $jobRepository + ); } public function getNumJobs():int { diff --git a/src/RunnerFactory.php b/src/RunnerFactory.php index 48f5ce2..8f66b44 100644 --- a/src/RunnerFactory.php +++ b/src/RunnerFactory.php @@ -2,7 +2,7 @@ namespace Gt\Cron; class RunnerFactory { - public static function createForProject( + public function createForProject( string $projectDirectory, string $fileName = "crontab" ):Runner { diff --git a/test/phpunit/JobRepositoryTest.php b/test/phpunit/JobRepositoryTest.php index bd196a1..ba20474 100644 --- a/test/phpunit/JobRepositoryTest.php +++ b/test/phpunit/JobRepositoryTest.php @@ -1,7 +1,7 @@ getCommand()); } - protected function mockExpression():CronExpression { - $expression = self::createMock(CronExpression::class); - /** @var CronExpression $expression */ + protected function mockExpression():Expression { + $expression = self::createMock(Expression::class); + /** @var Expression $expression */ return $expression; } } diff --git a/test/phpunit/JobTest.php b/test/phpunit/JobTest.php index ba2f9fd..b259144 100644 --- a/test/phpunit/JobTest.php +++ b/test/phpunit/JobTest.php @@ -1,12 +1,13 @@ run(); } + /** @runInSeparateProcess */ + public function testRunScriptCaptureOutput():void { + $command = PHP_BINARY . " -r " + . escapeshellarg("fwrite(STDOUT, 'out');fwrite(STDERR, 'err');"); + + $job = new Job( + $this->mockExpression(), + $command, + ScriptOutputMode::CAPTURE + ); + + $job->run(); + + self::assertSame("out", $job->getStdout()); + self::assertSame("err", $job->getStderr()); + } + + /** @runInSeparateProcess */ + public function testRunScriptInheritOutputDescriptor():void { + $job = new Job( + $this->mockExpression(), + "example", + ScriptOutputMode::INHERIT + ); + + $descriptor = null; + Override::setCallback("proc_open", function($command, $descriptorArg) use(&$descriptor) { + $descriptor = $descriptorArg; + return "EXAMPLE_PROCESS"; + }); + Override::load("proc_get_status"); + Override::setCallback("proc_close", function() { + }); + + $job->run(); + + self::assertSame(["file", "php://stdout", "w"], $descriptor[1]); + self::assertSame(["file", "php://stderr", "w"], $descriptor[2]); + } + public static function assertDateTimeEquals( DateTime $expected, DateTime $actual, @@ -199,7 +240,7 @@ public static function assertDateTimeEquals( ); } - protected function mockExpression(int...$wait):CronExpression { + protected function mockExpression(int...$wait):Expression { $runDateCallbackCount = 0; $runDate = []; @@ -212,7 +253,7 @@ protected function mockExpression(int...$wait):CronExpression { $runDate []= $d; } - $expression = self::createMock(CronExpression::class); + $expression = self::createMock(Expression::class); $expression->method("isDue") ->willReturnOnConsecutiveCalls(...$isDue); $expression->method("getNextRunDate") @@ -223,7 +264,7 @@ protected function mockExpression(int...$wait):CronExpression { return $value; }); - /** @var CronExpression $expression */ + /** @var Expression $expression */ return $expression; } } diff --git a/test/phpunit/RunnerFactoryTest.php b/test/phpunit/RunnerFactoryTest.php index 19c91a2..4c99d12 100644 --- a/test/phpunit/RunnerFactoryTest.php +++ b/test/phpunit/RunnerFactoryTest.php @@ -16,7 +16,7 @@ public function testCreateForProjectNoCrontabFile() { ]); mkdir($dir, 0775, true); self::expectException(CrontabNotFoundException::class); - RunnerFactory::createForProject($dir); + (new RunnerFactory())->createForProject($dir); } public function testCreateForProject() { @@ -31,10 +31,10 @@ public function testCreateForProject() { $dir, "crontab", ])); - $runner = RunnerFactory::createForProject( + $runner = (new RunnerFactory())->createForProject( $dir ); self::assertInstanceOf(Runner::class, $runner); } -} \ No newline at end of file +} diff --git a/test/phpunit/RunnerTest.php b/test/phpunit/RunnerTest.php index 86c0f9e..9bacb29 100644 --- a/test/phpunit/RunnerTest.php +++ b/test/phpunit/RunnerTest.php @@ -300,6 +300,20 @@ public function testComments() { self::assertEquals(2, $runner->getNumJobs()); } + public function testNicknameExpressions():void { + $cronContents = <<mockJobRepository(0), + $this->mockQueueRepository(0), + $cronContents + ); + + self::assertEquals(1, $runner->getNumJobs()); + } + public function testOnlyComments() { $cronContents = << Date: Mon, 16 Mar 2026 17:03:18 +0000 Subject: [PATCH 2/6] feature: add implementing classes --- src/CronExpression.php | 268 ++++++++++++++++++++++++++++++++++++++ src/CrontabParser.php | 66 ++++++++++ src/Expression.php | 9 ++ src/ExpressionFactory.php | 8 ++ src/ScriptOutputMode.php | 8 ++ 5 files changed, 359 insertions(+) create mode 100644 src/CronExpression.php create mode 100644 src/CrontabParser.php create mode 100644 src/Expression.php create mode 100644 src/ExpressionFactory.php create mode 100644 src/ScriptOutputMode.php diff --git a/src/CronExpression.php b/src/CronExpression.php new file mode 100644 index 0000000..2603b2a --- /dev/null +++ b/src/CronExpression.php @@ -0,0 +1,268 @@ + */ + private const NICKNAME_MAP = [ + "@yearly" => "0 0 1 1 *", + "@annually" => "0 0 1 1 *", + "@monthly" => "0 0 1 * *", + "@weekly" => "0 0 * * 0", + "@daily" => "0 0 * * *", + "@hourly" => "0 * * * *", + ]; + + /** @var array */ + private const MONTH_MAP = [ + "JAN" => 1, + "FEB" => 2, + "MAR" => 3, + "APR" => 4, + "MAY" => 5, + "JUN" => 6, + "JUL" => 7, + "AUG" => 8, + "SEP" => 9, + "OCT" => 10, + "NOV" => 11, + "DEC" => 12, + ]; + + /** @var array */ + private const WEEKDAY_MAP = [ + "SUN" => 0, + "MON" => 1, + "TUE" => 2, + "WED" => 3, + "THU" => 4, + "FRI" => 5, + "SAT" => 6, + ]; + + private const MAX_LOOKAHEAD_MINUTES = 525600 * 5; + + /** @var array */ + private array $minuteSet; + /** @var array */ + private array $hourSet; + /** @var array */ + private array $dayOfMonthSet; + /** @var array */ + private array $monthSet; + /** @var array */ + private array $dayOfWeekSet; + + private bool $dayOfMonthWildcard; + private bool $dayOfWeekWildcard; + + public function __construct(string $expression) { + $expression = $this->expandNickname($expression); + $parts = preg_split('/\s+/', trim($expression)); + + if(!$parts || count($parts) !== 5) { + throw new InvalidArgumentException("$expression is not a valid CRON expression"); + } + + $this->minuteSet = $this->parseField($parts[0], 0, 59); + $this->hourSet = $this->parseField($parts[1], 0, 23); + [$this->dayOfMonthSet, $this->dayOfMonthWildcard] = $this->parseFieldWithWildcard($parts[2], 1, 31); + $this->monthSet = $this->parseField($parts[3], 1, 12, self::MONTH_MAP); + [$this->dayOfWeekSet, $this->dayOfWeekWildcard] = $this->parseFieldWithWildcard( + $parts[4], + 0, + 7, + self::WEEKDAY_MAP, + true + ); + } + + public function isDue(DateTime $now):bool { + $candidate = clone $now; + $candidate->setTime( + (int)$candidate->format("H"), + (int)$candidate->format("i"), + 0 + ); + + return $this->matches($candidate); + } + + public function getNextRunDate(?DateTime $now = null):DateTime { + $candidate = clone ($now ?? new DateTime()); + $candidate->setTime( + (int)$candidate->format("H"), + (int)$candidate->format("i"), + 0 + ); + $candidate->modify("+1 minute"); + + for($i = 0; $i < self::MAX_LOOKAHEAD_MINUTES; $i++) { + if($this->matches($candidate)) { + return clone $candidate; + } + + $candidate->modify("+1 minute"); + } + + throw new RuntimeException("Unable to calculate next run date"); + } + + private function expandNickname(string $expression):string { + $expression = trim($expression); + return self::NICKNAME_MAP[$expression] ?? $expression; + } + + private function matches(DateTime $candidate):bool { + $minute = (int)$candidate->format("i"); + $hour = (int)$candidate->format("G"); + $dayOfMonth = (int)$candidate->format("j"); + $month = (int)$candidate->format("n"); + $dayOfWeek = (int)$candidate->format("w"); + + if(!isset($this->minuteSet[$minute]) || !isset($this->hourSet[$hour]) || !isset($this->monthSet[$month])) { + return false; + } + + $dayOfMonthMatches = isset($this->dayOfMonthSet[$dayOfMonth]); + $dayOfWeekMatches = isset($this->dayOfWeekSet[$dayOfWeek]); + + if($this->dayOfMonthWildcard && $this->dayOfWeekWildcard) { + return true; + } + + if($this->dayOfMonthWildcard) { + return $dayOfWeekMatches; + } + + if($this->dayOfWeekWildcard) { + return $dayOfMonthMatches; + } + + return $dayOfMonthMatches || $dayOfWeekMatches; + } + + /** + * @param array $nameMap + * @return array + */ + private function parseField( + string $field, + int $min, + int $max, + array $nameMap = [], + bool $normaliseWeekday = false + ):array { + [$set] = $this->parseFieldWithWildcard($field, $min, $max, $nameMap, $normaliseWeekday); + return $set; + } + + /** + * @param array $nameMap + * @return array{0:array,1:bool} + */ + private function parseFieldWithWildcard( + string $field, + int $min, + int $max, + array $nameMap = [], + bool $normaliseWeekday = false + ):array { + $field = strtoupper(trim($field)); + $isWildcard = $field === "*" || $field === "?"; + $set = []; + + foreach(explode(",", $field) as $segment) { + $segment = trim($segment); + if($segment === "") { + throw new InvalidArgumentException("Invalid CRON field value $field"); + } + + foreach($this->expandSegment($segment, $min, $max, $nameMap, $normaliseWeekday) as $value) { + $set[$value] = true; + } + } + + return [$set, $isWildcard]; + } + + /** + * @param array $nameMap + * @return array + */ + private function expandSegment( + string $segment, + int $min, + int $max, + array $nameMap, + bool $normaliseWeekday + ):array { + $step = 1; + if(str_contains($segment, "/")) { + [$segment, $stepPart] = explode("/", $segment, 2); + if($stepPart === "" || !ctype_digit($stepPart) || (int)$stepPart < 1) { + throw new InvalidArgumentException("Invalid CRON field value $segment/$stepPart"); + } + $step = (int)$stepPart; + } + + if($segment === "*" || $segment === "?") { + $start = $min; + $end = $max; + } + elseif(str_contains($segment, "-")) { + [$startPart, $endPart] = explode("-", $segment, 2); + $start = $this->normaliseValue($startPart, $min, $max, $nameMap, $normaliseWeekday); + $end = $this->normaliseValue($endPart, $min, $max, $nameMap, $normaliseWeekday); + if($end < $start) { + throw new InvalidArgumentException("Invalid CRON field value $segment"); + } + } + else { + $start = $this->normaliseValue($segment, $min, $max, $nameMap, $normaliseWeekday); + $end = $start; + } + + $values = []; + for($value = $start; $value <= $end; $value += $step) { + array_push($values, $normaliseWeekday && $value === 7 ? 0 : $value); + } + + return $values; + } + + /** + * @param array $nameMap + */ + private function normaliseValue( + string $value, + int $min, + int $max, + array $nameMap, + bool $normaliseWeekday + ):int { + $value = strtoupper(trim($value)); + + if(isset($nameMap[$value])) { + return $nameMap[$value]; + } + + if(!preg_match('/^\d+$/', $value)) { + throw new InvalidArgumentException("Invalid CRON field value $value"); + } + + $intValue = (int)$value; + if($normaliseWeekday && $intValue === 7) { + return 7; + } + + if($intValue < $min || $intValue > $max) { + throw new InvalidArgumentException("Invalid CRON field value $value"); + } + + return $intValue; + } +} diff --git a/src/CrontabParser.php b/src/CrontabParser.php new file mode 100644 index 0000000..24d9b54 --- /dev/null +++ b/src/CrontabParser.php @@ -0,0 +1,66 @@ +expressionFactory ??= new ExpressionFactory(); + } + + public function parseIntoQueue( + string $contents, + Queue $queue, + JobRepository $jobRepository + ):int { + $numJobs = 0; + + foreach(explode("\n", $contents) as $line) { + $line = trim($line); + if($line === "" || $line[0] === "#") { + continue; + } + + [$crontab, $command] = $this->parseLine($line); + + try { + $queue->add( + $jobRepository->create( + $this->expressionFactory->create($crontab), + $command + ) + ); + } + catch(InvalidArgumentException $exception) { + throw new ParseException("Invalid syntax: $line"); + } + + $numJobs++; + } + + return $numJobs; + } + + /** @return array{0:string,1:string} */ + public function parseLine(string $line):array { + preg_match( + "/^(?P@\S+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(?P.+)$/", + $line, + $matches + ); + + $crontab = $matches["crontab"] ?? null; + $command = $matches["command"] ?? null; + + if(is_null($crontab) || is_null($command)) { + throw new ParseException("Invalid syntax: $line"); + } + + return [ + trim($crontab), + trim($command), + ]; + } +} diff --git a/src/Expression.php b/src/Expression.php new file mode 100644 index 0000000..6308882 --- /dev/null +++ b/src/Expression.php @@ -0,0 +1,9 @@ + Date: Thu, 26 Mar 2026 14:14:59 +0000 Subject: [PATCH 3/6] docs: examples for #11 --- example/01-run-due-jobs.php | 29 ++++++++++++++ example/02-next-job.php | 31 +++++++++++++++ example/03-nickname-expressions.php | 31 +++++++++++++++ example/04-custom-expression-factory.php | 49 ++++++++++++++++++++++++ example/README.md | 12 ++++++ 5 files changed, 152 insertions(+) create mode 100644 example/01-run-due-jobs.php create mode 100644 example/02-next-job.php create mode 100644 example/03-nickname-expressions.php create mode 100644 example/04-custom-expression-factory.php create mode 100644 example/README.md diff --git a/example/01-run-due-jobs.php b/example/01-run-due-jobs.php new file mode 100644 index 0000000..9723072 --- /dev/null +++ b/example/01-run-due-jobs.php @@ -0,0 +1,29 @@ +format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; + +$queue = new Queue($now); +$crontabParser = new CrontabParser(new ExpressionFactory()); +$jobRepository = new JobRepository(ScriptOutputMode::INHERIT); +$crontabParser->parseIntoQueue($crontab, $queue, $jobRepository); + +echo "Command output:" . PHP_EOL; +$runCommandList = $queue->runDueJobsAndGetCommands(); +echo "Jobs ran: " . count($runCommandList) . PHP_EOL; diff --git a/example/02-next-job.php b/example/02-next-job.php new file mode 100644 index 0000000..c52e9e5 --- /dev/null +++ b/example/02-next-job.php @@ -0,0 +1,31 @@ +parseIntoQueue($crontab, $queue, $jobRepository); + +echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; + +echo "Command output:" . PHP_EOL; +$queue->runDueJobsAndGetCommands(); + +echo "Next job: " . $queue->timeOfNextJob()?->format("Y-m-d H:i:s") . PHP_EOL; +echo "Next command: " . $queue->commandOfNextJob() . PHP_EOL; diff --git a/example/03-nickname-expressions.php b/example/03-nickname-expressions.php new file mode 100644 index 0000000..efdf89e --- /dev/null +++ b/example/03-nickname-expressions.php @@ -0,0 +1,31 @@ +parseIntoQueue($crontab, $queue, $jobRepository); + +echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; + +echo "Command output:" . PHP_EOL; +$runCommandList = $queue->runDueJobsAndGetCommands(); +echo "Commands due right now: " . implode(", ", $runCommandList) . PHP_EOL; +echo "Next job: " . $queue->timeOfNextJob()?->format("Y-m-d H:i:s") . PHP_EOL; +echo "Next command: " . $queue->commandOfNextJob() . PHP_EOL; diff --git a/example/04-custom-expression-factory.php b/example/04-custom-expression-factory.php new file mode 100644 index 0000000..90f4e26 --- /dev/null +++ b/example/04-custom-expression-factory.php @@ -0,0 +1,49 @@ +format("Y-m-d H:i:s") . PHP_EOL; +echo "Crontab:" . PHP_EOL; +echo $crontab . PHP_EOL . PHP_EOL; +echo "This example injects a custom ExpressionFactory to handle @start." . PHP_EOL; +echo "Command output:" . PHP_EOL; + +$queue = new Queue($now); +$jobRepository = new JobRepository(ScriptOutputMode::INHERIT); +(new CrontabParser($customExpressionFactory)) + ->parseIntoQueue($crontab, $queue, $jobRepository); + +$runCommandList = $queue->runDueJobsAndGetCommands(); +echo "Jobs ran: " . count($runCommandList) . PHP_EOL; diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..af97866 --- /dev/null +++ b/example/README.md @@ -0,0 +1,12 @@ +# Examples + +Run each example from the project root with `php`: + +```bash +php example/01-run-due-jobs.php +php example/02-next-job.php +php example/03-nickname-expressions.php +php example/04-custom-expression-factory.php +``` + +Each script embeds its own crontab string so you can read the schedule and the code together. From 7f20506caf9158446659076d6d65a2c0503ff8e7 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Thu, 26 Mar 2026 14:16:01 +0000 Subject: [PATCH 4/6] test: CronExpression --- test/phpunit/CronExpressionTest.php | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 test/phpunit/CronExpressionTest.php diff --git a/test/phpunit/CronExpressionTest.php b/test/phpunit/CronExpressionTest.php new file mode 100644 index 0000000..e2c8960 --- /dev/null +++ b/test/phpunit/CronExpressionTest.php @@ -0,0 +1,55 @@ +isDue(new DateTime("2026-03-11 12:34:56"))); + } + + public function testGetNextRunDateSkipsCurrentMinute():void { + $expression = new CronExpression("* * * * *"); + $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-11 12:34:56")); + self::assertSame("2026-03-11 12:35:00", $nextRunDate->format("Y-m-d H:i:s")); + } + + public function testStepRangeAndListSyntax():void { + $expression = new CronExpression("*/15 9-17 * * 1,3,5"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-13 09:30:20"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-13 09:31:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-14 09:30:00"))); + } + + public function testMonthAndWeekdayNames():void { + $expression = new CronExpression("0 22 * JAN MON-FRI"); + + self::assertTrue($expression->isDue(new DateTime("2027-01-04 22:00:10"))); + self::assertFalse($expression->isDue(new DateTime("2027-02-04 22:00:00"))); + self::assertFalse($expression->isDue(new DateTime("2027-01-03 22:00:00"))); + } + + public function testDayOfMonthAndDayOfWeekUseCronOrSemantics():void { + $expression = new CronExpression("0 12 13 * FRI"); + + self::assertTrue($expression->isDue(new DateTime("2026-03-13 12:00:00"))); + self::assertTrue($expression->isDue(new DateTime("2026-11-06 12:00:00"))); + self::assertFalse($expression->isDue(new DateTime("2026-03-12 12:00:00"))); + } + + public function testNicknameExpansion():void { + $expression = new CronExpression("@daily"); + $nextRunDate = $expression->getNextRunDate(new DateTime("2026-03-11 12:34:00")); + self::assertSame("2026-03-12 00:00:00", $nextRunDate->format("Y-m-d H:i:s")); + } + + public function testInvalidFieldThrows():void { + self::expectException(InvalidArgumentException::class); + new CronExpression("* * * ABC *"); + } +} From eaa9af716625bc4b3de3dacaa576a5f0ae5c7974 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Thu, 26 Mar 2026 14:18:04 +0000 Subject: [PATCH 5/6] build: php8.2 compat --- composer.lock | 70 ++++++++++++++++++++++++--------------------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/composer.lock b/composer.lock index 57bba4d..c18b40d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,29 +8,27 @@ "packages": [ { "name": "phpgt/cli", - "version": "v1.3.5", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/PhpGt/Cli.git", - "reference": "42aeef24ab9789907358002fdcc37cb3a2e26b4b" + "reference": "71deb9cdc5a3ea8bfb665faa29739badbf61e9da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/Cli/zipball/42aeef24ab9789907358002fdcc37cb3a2e26b4b", - "reference": "42aeef24ab9789907358002fdcc37cb3a2e26b4b", + "url": "https://api.github.com/repos/PhpGt/Cli/zipball/71deb9cdc5a3ea8bfb665faa29739badbf61e9da", + "reference": "71deb9cdc5a3ea8bfb665faa29739badbf61e9da", "shasum": "" }, "require": { "ext-json": "*", "ext-readline": "*", - "php": ">=8.2", - "phpgt/daemon": "^1.1.3" + "php": ">=8.0", + "phpgt/daemon": "^v1.1" }, "require-dev": { - "phpmd/phpmd": "^2.13", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.7" + "phpstan/phpstan": "^v1.8", + "phpunit/phpunit": "^v9.5" }, "type": "library", "autoload": { @@ -57,7 +55,7 @@ ], "support": { "issues": "https://github.com/PhpGt/Cli/issues", - "source": "https://github.com/PhpGt/Cli/tree/v1.3.5" + "source": "https://github.com/PhpGt/Cli/tree/v1.3.4" }, "funding": [ { @@ -65,31 +63,28 @@ "type": "github" } ], - "time": "2024-05-08T17:45:44+00:00" + "time": "2023-09-18T10:06:17+00:00" }, { "name": "phpgt/daemon", - "version": "v1.1.5", + "version": "v1.1.2", "source": { "type": "git", - "url": "https://github.com/phpgt/Daemon.git", - "reference": "413e16b54de6e1fd5c2b646b485f88a86dfedd9a" + "url": "https://github.com/PhpGt/Daemon.git", + "reference": "6490df99a22818149f30e3af408002ea7f73e035" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpgt/Daemon/zipball/413e16b54de6e1fd5c2b646b485f88a86dfedd9a", - "reference": "413e16b54de6e1fd5c2b646b485f88a86dfedd9a", + "url": "https://api.github.com/repos/PhpGt/Daemon/zipball/6490df99a22818149f30e3af408002ea7f73e035", + "reference": "6490df99a22818149f30e3af408002ea7f73e035", "shasum": "" }, "require": { - "ext-pcntl": "*", - "php": ">=8.1" + "php": ">=7.4" }, "require-dev": { - "phpmd/phpmd": "^2.13", - "phpstan/phpstan": "^v1.10", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.7" + "phpstan/phpstan": ">=0.12.42", + "phpunit/phpunit": "9.*" }, "type": "library", "autoload": { @@ -100,16 +95,16 @@ "notification-url": "https://packagist.org/downloads/", "description": "Background script execution with cross-platform compatible streaming.", "support": { - "issues": "https://github.com/phpgt/Daemon/issues", - "source": "https://github.com/phpgt/Daemon/tree/v1.1.5" + "issues": "https://github.com/PhpGt/Daemon/issues", + "source": "https://github.com/PhpGt/Daemon/tree/v1.1.2" }, "funding": [ { - "url": "https://github.com/sponsors/PhpGt", + "url": "https://github.com/phpgt", "type": "github" } ], - "time": "2026-03-11T14:11:10+00:00" + "time": "2021-02-02T17:33:16+00:00" } ], "packages-dev": [ @@ -2815,25 +2810,26 @@ }, { "name": "symfony/var-exporter", - "version": "v8.0.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", - "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2871,7 +2867,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -2891,7 +2887,7 @@ "type": "tidelift" } ], - "time": "2025-11-05T18:53:00+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "theseer/tokenizer", From d988b7dcb2e8ab3e9239f8881ab835f2b3b6f06f Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Thu, 26 Mar 2026 14:58:55 +0000 Subject: [PATCH 6/6] feature: custom expressions closes #11 --- src/CronExpression.php | 131 +----------- src/FunctionCommand.php | 40 ++++ src/Job.php | 223 ++------------------- src/ResolvedScriptCommand.php | 48 +++++ src/ScriptResult.php | 10 + src/ScriptRunner.php | 109 ++++++++++ test/phpunit/FunctionCommandTest.php | 50 +++++ test/phpunit/JobTest.php | 42 +++- test/phpunit/ResolvedScriptCommandTest.php | 39 ++++ test/phpunit/ScriptRunnerTest.php | 77 +++++++ 10 files changed, 432 insertions(+), 337 deletions(-) create mode 100644 src/FunctionCommand.php create mode 100644 src/ResolvedScriptCommand.php create mode 100644 src/ScriptResult.php create mode 100644 src/ScriptRunner.php create mode 100644 test/phpunit/FunctionCommandTest.php create mode 100644 test/phpunit/ResolvedScriptCommandTest.php create mode 100644 test/phpunit/ScriptRunnerTest.php diff --git a/src/CronExpression.php b/src/CronExpression.php index 31244ac..1836cc6 100644 --- a/src/CronExpression.php +++ b/src/CronExpression.php @@ -69,11 +69,11 @@ public function __construct(string $expression) { throw new InvalidArgumentException("$expression is not a valid CRON expression"); } - $this->minuteSet = $this->parseField($parts[0], 0, 59); - $this->hourSet = $this->parseField($parts[1], 0, 23); - [$this->dayOfMonthSet, $this->dayOfMonthWildcard] = $this->parseFieldWithWildcard($parts[2], 1, 31); - $this->monthSet = $this->parseField($parts[3], 1, 12, self::MONTH_MAP); - [$this->dayOfWeekSet, $this->dayOfWeekWildcard] = $this->parseFieldWithWildcard( + $this->minuteSet = $this->fieldParser->parseField($parts[0], 0, 59); + $this->hourSet = $this->fieldParser->parseField($parts[1], 0, 23); + [$this->dayOfMonthSet, $this->dayOfMonthWildcard] = $this->fieldParser->parseFieldWithWildcard($parts[2], 1, 31); + $this->monthSet = $this->fieldParser->parseField($parts[3], 1, 12, self::MONTH_MAP); + [$this->dayOfWeekSet, $this->dayOfWeekWildcard] = $this->fieldParser->parseFieldWithWildcard( $parts[4], 0, 7, @@ -146,125 +146,4 @@ private function matches(DateTime $candidate):bool { return $dayOfMonthMatches || $dayOfWeekMatches; } - - /** - * @param array $nameMap - * @return array - */ - private function parseField( - string $field, - int $min, - int $max, - array $nameMap = [], - bool $normaliseWeekday = false - ):array { - [$set] = $this->parseFieldWithWildcard($field, $min, $max, $nameMap, $normaliseWeekday); - return $set; - } - - /** - * @param array $nameMap - * @return array{0:array,1:bool} - */ - private function parseFieldWithWildcard( - string $field, - int $min, - int $max, - array $nameMap = [], - bool $normaliseWeekday = false - ):array { - $field = strtoupper(trim($field)); - $isWildcard = $field === "*" || $field === "?"; - $set = []; - - foreach(explode(",", $field) as $segment) { - $segment = trim($segment); - if($segment === "") { - throw new InvalidArgumentException("Invalid CRON field value $field"); - } - - foreach($this->expandSegment($segment, $min, $max, $nameMap, $normaliseWeekday) as $value) { - $set[$value] = true; - } - } - - return [$set, $isWildcard]; - } - - /** - * @param array $nameMap - * @return array - */ - private function expandSegment( - string $segment, - int $min, - int $max, - array $nameMap, - bool $normaliseWeekday - ):array { - $step = 1; - if(str_contains($segment, "/")) { - [$segment, $stepPart] = explode("/", $segment, 2); - if($stepPart === "" || !ctype_digit($stepPart) || (int)$stepPart < 1) { - throw new InvalidArgumentException("Invalid CRON field value $segment/$stepPart"); - } - $step = (int)$stepPart; - } - - if($segment === "*" || $segment === "?") { - $start = $min; - $end = $max; - } - elseif(str_contains($segment, "-")) { - [$startPart, $endPart] = explode("-", $segment, 2); - $start = $this->normaliseValue($startPart, $min, $max, $nameMap, $normaliseWeekday); - $end = $this->normaliseValue($endPart, $min, $max, $nameMap, $normaliseWeekday); - if($end < $start) { - throw new InvalidArgumentException("Invalid CRON field value $segment"); - } - } - else { - $start = $this->normaliseValue($segment, $min, $max, $nameMap, $normaliseWeekday); - $end = $start; - } - - $values = []; - for($value = $start; $value <= $end; $value += $step) { - array_push($values, $normaliseWeekday && $value === 7 ? 0 : $value); - } - - return $values; - } - - /** - * @param array $nameMap - */ - private function normaliseValue( - string $value, - int $min, - int $max, - array $nameMap, - bool $normaliseWeekday - ):int { - $value = strtoupper(trim($value)); - - if(isset($nameMap[$value])) { - return $nameMap[$value]; - } - - if(!preg_match('/^\d+$/', $value)) { - throw new InvalidArgumentException("Invalid CRON field value $value"); - } - - $intValue = (int)$value; - if($normaliseWeekday && $intValue === 7) { - return 7; - } - - if($intValue < $min || $intValue > $max) { - throw new InvalidArgumentException("Invalid CRON field value $value"); - } - - return $intValue; - } } diff --git a/src/FunctionCommand.php b/src/FunctionCommand.php new file mode 100644 index 0000000..ea1c972 --- /dev/null +++ b/src/FunctionCommand.php @@ -0,0 +1,40 @@ +callableName($command); + return str_contains($callable, "::") || is_callable($callable); + } + + public function execute(string $command):void { + $callableName = $this->callableName($command); + $callable = explode("::", $callableName); + if(!is_callable($callable)) { + throw new FunctionExecutionException($callableName); + } + + call_user_func_array($callable, $this->arguments($command)); + } + + protected function callableName(string $command):string { + $bracketPos = strpos($command, "("); + if($bracketPos === false) { + return trim($command); + } + + return trim(substr($command, 0, $bracketPos)); + } + + /** @return array */ + protected function arguments(string $command):array { + $bracketPos = strpos($command, "("); + if($bracketPos === false) { + return []; + } + + $argsString = substr($command, $bracketPos); + $argsString = trim($argsString, " ();"); + return str_getcsv($argsString); + } +} diff --git a/src/Job.php b/src/Job.php index 1732e1c..e486d6f 100644 --- a/src/Job.php +++ b/src/Job.php @@ -7,21 +7,25 @@ class Job { protected Expression $expression; protected string $command; protected bool $hasRun; - protected ScriptOutputMode $scriptOutputMode; protected string $stdout; protected string $stderr; + protected FunctionCommand $functionCommand; + protected ScriptRunner $scriptRunner; public function __construct( Expression $expression, string $command, - ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD + ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD, + ?FunctionCommand $functionCommand = null, + ?ScriptRunner $scriptRunner = null, ) { $this->expression = $expression; $this->command = $command; $this->hasRun = false; - $this->scriptOutputMode = $scriptOutputMode; $this->stdout = ""; $this->stderr = ""; + $this->functionCommand = $functionCommand ?? new FunctionCommand(); + $this->scriptRunner = $scriptRunner ?? new ScriptRunner($scriptOutputMode); } public function isDue(?DateTime $now = null):bool { @@ -56,13 +60,14 @@ public function run():void { $this->stdout = ""; $this->stderr = ""; - if($this->isFunction()) { - $this->executeFunction(); - } - else { -// Assume the command is a shell command. - $this->executeScript(); + if($this->functionCommand->isCallable($this->command)) { + $this->functionCommand->execute($this->command); + return; } + + $scriptResult = $this->scriptRunner->run($this->command); + $this->stdout = $scriptResult->stdout; + $this->stderr = $scriptResult->stderr; } public function hasRun():bool { @@ -74,204 +79,6 @@ public function resetRunFlag():void { } public function isFunction():bool { - $command = $this->command; - $bracketPos = strpos( - $command, - "(" - ); - if($bracketPos !== false) { - $command = substr($command, 0, $bracketPos); - $command = trim($command); - } - - return strstr($command, "::") - || is_callable($command); - } - - protected function executeFunction():void { - $command = $this->command; - $args = []; - $bracketPos = strpos($command, "("); - if($bracketPos !== false) { - $argsString = substr( - $command, - $bracketPos - ); - $argsString = trim($argsString, " ();"); - $args = str_getcsv($argsString, ",", "\"", "\\"); - - $command = substr( - $command, - 0, - $bracketPos - ); - $command = trim($command); - } - - $callable = explode("::", $command); - - if(!is_callable($callable)) { - throw new FunctionExecutionException($command); - } - call_user_func_array($callable, $args); - } - - protected function executeScript():void { - $command = $this->resolveScriptCommand(); - $descriptor = $this->createScriptDescriptor(); - $pipes = []; - - $proc = proc_open( - $command, - $descriptor, - $pipes - ); - - do { - if($proc) { - $status = proc_get_status($proc); - } - else { - $status = [ - "running" => false, - "exitcode" => -1, - ]; - } - }while($status["running"]); - - if($proc) { - $this->captureProcessOutput($pipes); - } - - if($status["exitcode"] > 0) { - throw new ScriptExecutionException( - $this->command - ); - } - - if($proc) { - $this->closePipes($pipes); - proc_close($proc); - } - } - - /** @return array */ - protected function createScriptDescriptor():array { - $stdin = ["pipe", "r"]; - - return match($this->scriptOutputMode) { - ScriptOutputMode::INHERIT => [ - 0 => $stdin, - 1 => ["file", "php://stdout", "w"], - 2 => ["file", "php://stderr", "w"], - ], - ScriptOutputMode::CAPTURE => [ - 0 => $stdin, - 1 => ["pipe", "w"], - 2 => ["pipe", "w"], - ], - default => [ - 0 => $stdin, - 1 => ["file", $this->nullDevice(), "w"], - 2 => ["file", $this->nullDevice(), "w"], - ], - }; - } - - /** @param array $pipes */ - protected function captureProcessOutput(array $pipes):void { - if($this->scriptOutputMode !== ScriptOutputMode::CAPTURE) { - return; - } - - if(isset($pipes[1]) && is_resource($pipes[1])) { - $this->stdout = stream_get_contents($pipes[1]) ?: ""; - } - - if(isset($pipes[2]) && is_resource($pipes[2])) { - $this->stderr = stream_get_contents($pipes[2]) ?: ""; - } - } - - /** @param array $pipes */ - protected function closePipes(array $pipes):void { - foreach($pipes as $pipe) { - if(is_resource($pipe)) { - fclose($pipe); - } - } - } - - protected function nullDevice():string { - if(PHP_OS_FAMILY === "Windows") { - return "NUL"; - } - - return "/dev/null"; - } - - protected function resolveScriptCommand():string { - $scriptParts = $this->parseScriptCommand($this->command); - if(is_null($scriptParts)) { - return $this->command; - } - - $script = $this->normaliseScriptName($scriptParts["script"]); - if(!$this->isValidScriptName($script)) { - return $this->command; - } - - $scriptPath = $this->getLocalCronScriptPath($script); - if(!is_file($scriptPath)) { - return $this->command; - } - - return PHP_BINARY - . " " - . escapeshellarg($scriptPath) - . $scriptParts["args"]; - } - - /** @return null|array{script:string,args:string} */ - protected function parseScriptCommand(string $command):?array { - $matches = []; - if(!preg_match( - "/^(?P