From 81c2d2a8226bea5f4045f61c5f75725727f03df3 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 11 Mar 2026 19:19:21 +0000 Subject: [PATCH 01/11] 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 02/11] 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: Mon, 16 Mar 2026 17:09:35 +0000 Subject: [PATCH 03/11] ci: low artifact retention --- .github/workflows/ci.yml | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b2b4e..34937c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: with: name: build-artifact-${{ matrix.php }} path: /tmp/github-actions + retention-days: 1 phpunit: runs-on: ubuntu-latest @@ -163,21 +164,3 @@ jobs: php_version: ${{ matrix.php }} path: src/ standard: phpcs.xml - - remove_old_artifacts: - runs-on: ubuntu-latest - - permissions: - actions: write - - steps: - - name: Remove old artifacts for prior workflow runs on this repository - env: - GH_TOKEN: ${{ github.token }} - run: | - gh api "/repos/${{ github.repository }}/actions/artifacts" | jq ".artifacts[] | select(.name | startswith(\"build-artifact\")) | .id" > artifact-id-list.txt - while read id - do - echo -n "Deleting artifact ID $id ... " - gh api --method DELETE /repos/${{ github.repository }}/actions/artifacts/$id && echo "Done" - done Date: Mon, 16 Mar 2026 17:10:16 +0000 Subject: [PATCH 04/11] tidy: refactor job to reduce complexity --- composer.json | 14 +++++++ src/Job.php | 69 ++--------------------------------- src/ScriptCommandResolver.php | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 66 deletions(-) create mode 100644 src/ScriptCommandResolver.php diff --git a/composer.json b/composer.json index f8e2e30..5e07e70 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,20 @@ "bin/cron" ], + "scripts": { + "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", + "phpunit:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --configuration phpunit.xml --coverage-text", + "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/Job.php b/src/Job.php index 1732e1c..8b54ff5 100644 --- a/src/Job.php +++ b/src/Job.php @@ -4,6 +4,7 @@ use DateTime; class Job { + protected ScriptCommandResolver $scriptCommandResolver; protected Expression $expression; protected string $command; protected bool $hasRun; @@ -16,6 +17,7 @@ public function __construct( string $command, ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD ) { + $this->scriptCommandResolver = new ScriptCommandResolver(); $this->expression = $expression; $this->command = $command; $this->hasRun = false; @@ -117,7 +119,7 @@ protected function executeFunction():void { } protected function executeScript():void { - $command = $this->resolveScriptCommand(); + $command = $this->scriptCommandResolver->resolve($this->command); $descriptor = $this->createScriptDescriptor(); $pipes = []; @@ -209,69 +211,4 @@ protected function nullDevice():string { 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