diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a195d73..5f4ee2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,11 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: php-actions/composer@v5 + - uses: php-actions/composer@v6 - name: PHPUnit Tests - uses: php-actions/phpunit@v2 + uses: php-actions/phpunit@v3 with: configuration: phpunit.xml.dist version: ${{ matrix.phpunit }} php_version: ${{ matrix.php }} + php_extensions: pcntl diff --git a/doc/plugins.md b/doc/plugins.md index e5d7c38..dc50b55 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -56,7 +56,7 @@ array-like interface (`ArrayAccess`) to reading and writing arguments. The following events are defined: -* `loco.app.boot` (*global-only*): Fires immediately when the application starst +* `loco.app.boot` (*global-only*): Fires immediately when the application starts * `loco.app.run` (*global-only*): Fires when the application begins executing a command * `loco.app.commands` (*global-only*): Fires when the application builds a list of available commands * __Argument__: `$e['commands`]`: alterable list of commands @@ -74,6 +74,10 @@ The following events are defined: * `loco.env.merge`: Fires whenever a series of environments are merged to create a new environment. * __Argument__: `$e['srcs']`: an array of `LocoEnv`, ordered by priority * __Argument__: `$e['env']`: the new `LocoEnv` built by combining the various sources +* `loco.expr.create`: Initializes the expression language. + * __Argument__: `$e['functions']`: The list of user-callable functions, keyed by name. Each item is a callback. +* `loco.expr.functions`: Fires when the application needs to identify user-callable functions + * __Argument__: `$e['functions']`: The list of user-callable functions, keyed by name. Each item is a callback. * `loco.service.create`: Fires after a `LocoService` is instantiated * __Argument__: `$e['service']`: the `LocoService` which needs an environment * `loco.service.mergeEnv`: Fires whenever a set of environments are merged to build the effective service-environment. diff --git a/doc/specs.md b/doc/specs.md index d22981d..6e0a075 100644 --- a/doc/specs.md +++ b/doc/specs.md @@ -150,14 +150,29 @@ any nested references. `loco` *only* evaluates a nested reference if it's decla If a variable is defined recursively (e.g. `PATH=/opt/foo/bin:$PATH`), then it incorporates the value from the parent scope. -There is limited support for computation (eg `dirname` and `basename`). +## Specification: Inline function calls + +There is experimental support for assigning variables with inline function calls (following a subset of shell-style syntax). ```yaml environment: - - FOO_BASE=$(dirname $LOCO_VAR)/sibling + - FOO_NAME=$(basename "$FILE") + - FOO_PATH=$(dirname "$FILE") + - FOO_SIBLING=$(dirname "$FILE")/sibling + - GREETING=$(echo "Hello $NAME")! ``` -If further computation is required, then use a [plugin](plugins.md). +Important details: + +- These are not literally `bash` expressions. +- The syntax and semantics may still change in subtle ways. +- These are internal functions -- not external programs. +- Subexpressions are prohibited from having `(` or `)` characters. `$(echo "foo()")` will not work. +- `$(basename "$FILE")` and `$(basename $FILE)` are equivalent -- whitespace in variable content does not currently expand to multiple parameters. Never-the-less, you should use the quotes for consistency/readability/portability. + + + +If further computation is required, then use a [plugin](plugins.md) to define custom variables or custom functions. ## Specification: Initializing config files diff --git a/src/LocoEnv.php b/src/LocoEnv.php index 6bbf9f9..d6f7c28 100644 --- a/src/LocoEnv.php +++ b/src/LocoEnv.php @@ -3,15 +3,27 @@ class LocoEnv { + /** + * @var \Loco\LocoEvaluator + */ + protected $evaluator; + protected $specs = []; /** - * @param array $locoEnvs + * @param LocoEnv[] $locoEnvs * @return LocoEnv */ public static function merge($locoEnvs) { $merged = new static(); foreach ($locoEnvs as $locoEnv) { + if ($merged->evaluator === NULL) { + $merged->evaluator = $locoEnv->evaluator; + } + elseif ($merged->evaluator !== $locoEnv->evaluator) { + throw new \RuntimeException('Error: Cannot merge incompatible evaluators'); + } + foreach ($locoEnv->specs as $envKey => $envSpec) { if (!isset($merged->specs[$envKey])) { $merged->specs[$envKey] = $envSpec; @@ -28,14 +40,16 @@ public static function merge($locoEnvs) { } /** - * @param array $asgnExprs + * @param string[] $asgnExprs * Ex: ['FOO=123', 'BAR=abc_$FOO'] + * @param LocoEvaluator $evaluator * @return LocoEnv */ - public static function create($asgnExprs) { + public static function create($asgnExprs, LocoEvaluator $evaluator) { $env = new static(); + $env->evaluator = $evaluator; foreach ($asgnExprs as $asgnExpr) { - list ($key, $valExpr) = explode('=', $asgnExpr, 2); + [$key, $valExpr] = explode('=', $asgnExpr, 2); $env->set($key, $valExpr, TRUE); } Loco::filter('loco.env.create', ['env' => $env, 'assignments' => $asgnExprs]); @@ -119,22 +133,12 @@ public function evaluate($valExpr, $onMissing = 'exception') { * Ex: 'exception', 'null', 'keep' * @return string|NULL */ - public function evaluateSpec($spec, $onMissing = 'exception') { + protected function evaluateSpec($spec, $onMissing = 'exception') { if (!$spec['isDynamic']) { return $spec['value']; } - $valExpr = $spec['value']; - - if (empty($valExpr)) { - return $valExpr; - } - - $varExprRegex = '\$([a-zA-Z0-9_\{\}]+)'; // Ex: '$FOO' or '${FOO}' - $funcNameRegex = '[a-zA-Z-9_]+'; // Ex: 'basename' or 'dirname' - $funcExprRegex = '\$\((' . $funcNameRegex .') (' . $varExprRegex . ')\)'; // Ex: '$(basename $FOO)' $lookupVar = function($name) use ($onMissing, $spec) { - $name = preg_replace(';^\{(.*)\}$;', '\1', $name); if ($name === $spec['name']) { // Recursive value expression! Consult parent environment. return isset($spec['parent']) @@ -146,27 +150,7 @@ public function evaluateSpec($spec, $onMissing = 'exception') { } }; - return preg_replace_callback(';(' . $funcExprRegex . '|' . $varExprRegex . ');', function($mainMatch) use ($valExpr, $onMissing, $varExprRegex, $funcExprRegex, $spec, $lookupVar) { - if (preg_match(";^$varExprRegex$;", $mainMatch[1], $matches)) { - return $lookupVar($matches[1]); - } - elseif (preg_match(";^$funcExprRegex$;", $mainMatch[1], $matches)) { - $target = $lookupVar($matches[3]); - $func = $matches[1]; - switch ($func) { - case 'dirname': - return dirname($target); - - case 'basename': - return basename($target); - - default: - throw new \RuntimeException("Invalid function expression: " . $valExpr); - } - } - - throw new \RuntimeException("Malformed variable expression: " . $mainMatch[0]); - }, $valExpr); + return $this->evaluator->evaluate($spec['value'], $lookupVar); } } diff --git a/src/LocoEvaluator.php b/src/LocoEvaluator.php new file mode 100644 index 0000000..4875ded --- /dev/null +++ b/src/LocoEvaluator.php @@ -0,0 +1,74 @@ +evaluate($rawArg, $lookupVar); + } + + return $this->callFunction($func, ...$args); + } + + throw new \RuntimeException("Malformed variable expression: " . $mainMatch[0]); + }, $valExpr); + } + + /** + * Get a list of functions. + * + * For example, in the expression "cp foo $(dirname $BAR)", the "dirname" is a function. + * + * @return array + * Ex: ['basename' => function(string $one, string $two, ...): string] + * @experimental + */ + public function callFunction(string $function, ...$args): string { + if ($this->functions === NULL) { + $data = Loco::filter('loco.expr.functions', ['functions' => []]); + $this->functions = $data['functions']; + } + + if (isset($this->functions[$function])) { + return call_user_func_array($this->functions[$function], $args); + } + else { + throw new \RuntimeException("Invalid function: " . $function); + } + } + +} diff --git a/src/LocoPlugins.php b/src/LocoPlugins.php index 325d090..c6478c7 100644 --- a/src/LocoPlugins.php +++ b/src/LocoPlugins.php @@ -22,6 +22,9 @@ public function init() { // getcwd() . '/.loco/plugin' } + // Always load internal plugins + $paths[] = __DIR__ . '/plugin'; + foreach ($paths as $path) { if (file_exists($path) && is_dir($path)) { $this->load("$path/*.php"); diff --git a/src/LocoService.php b/src/LocoService.php index cde7568..9464ddb 100644 --- a/src/LocoService.php +++ b/src/LocoService.php @@ -96,11 +96,11 @@ public static function create($system, $name, $settings) { $svc->name = $name; $svc->systemd = $settings['systemd'] ?? []; $svc->enabled = isset($settings['enabled']) ? $settings['enabled'] : TRUE; - $svc->environment = LocoEnv::create(isset($settings['environment']) ? $settings['environment'] : []); + $svc->environment = LocoEnv::create(isset($settings['environment']) ? $settings['environment'] : [], $system->evaluator); $svc->environment->set('LOCO_SVC', $name, FALSE); $svc->environment->set('LOCO_SVC_VAR', '$LOCO_VAR/$LOCO_SVC', TRUE); $svc->environment->set('LOCO_SVC_CFG', '$LOCO_CFG/$LOCO_SVC', TRUE); - $svc->default_environment = LocoEnv::create(isset($settings['default_environment']) ? $settings['default_environment'] : []); + $svc->default_environment = LocoEnv::create(isset($settings['default_environment']) ? $settings['default_environment'] : [], $system->evaluator); foreach (['init', 'cleanup', 'depends'] as $key) { $svc->{$key} = isset($settings[$key]) ? ((array) $settings[$key]) : []; } diff --git a/src/LocoSystem.php b/src/LocoSystem.php index a970bf2..5dd6c4e 100644 --- a/src/LocoSystem.php +++ b/src/LocoSystem.php @@ -22,6 +22,11 @@ class LocoSystem { */ public $format; + /** + * @var LocoEvaluator + */ + public $evaluator; + /** * Default values for environment variables (defined at system-level in loco.yml). * @@ -67,8 +72,11 @@ public static function create($configFile, $prjDir, $settings) { $system->config = $settings; $system->format = isset($settings['format']) ? $settings['format'] : 'loco-0.1'; - $system->default_environment = LocoEnv::create(isset($settings['default_environment']) ? $settings['default_environment'] : []); - $system->environment = LocoEnv::create(isset($settings['environment']) ? $settings['environment'] : []); + $filtered = Loco::filter('loco.expr.create', ['settings' => $settings, 'evaluator' => new LocoEvaluator()]); + $system->evaluator = $filtered['evaluator']; + + $system->default_environment = LocoEnv::create(isset($settings['default_environment']) ? $settings['default_environment'] : [], $system->evaluator); + $system->environment = LocoEnv::create(isset($settings['environment']) ? $settings['environment'] : [], $system->evaluator); $system->environment->set('LOCO_CFG_YML', $configFile, FALSE); if ($system->environment->getSpec('LOCO_PRJ') === NULL) { $system->environment->set('LOCO_PRJ', $prjDir, FALSE); @@ -82,7 +90,7 @@ public static function create($configFile, $prjDir, $settings) { if ($system->environment->getSpec('PATH') === NULL && file_exists($binDir = "$prjDir/.loco/bin")) { $system->environment->set('PATH', $binDir . PATH_SEPARATOR . '${PATH}', TRUE); } - $system->global_environment = LocoEnv::create([]); + $system->global_environment = LocoEnv::create([], $system->evaluator); $globalEnv = version_compare(PHP_VERSION, '7.1.alpha', '>=') ? getenv() : $_ENV; foreach ($globalEnv as $k => $v) { $system->global_environment->set($k, $v, FALSE); diff --git a/src/Utils/ShellString.php b/src/Utils/ShellString.php new file mode 100644 index 0000000..bbeb2c2 --- /dev/null +++ b/src/Utils/ShellString.php @@ -0,0 +1,125 @@ + static::T_SPACE, + "\t" => static::T_SPACE, + "\n" => static::T_SPACE, + '"' => static::T_DBL, + "'" => static::T_SNG, + '\\' => static::T_ESC, + ]; + + // Accumulate a list of shell parts. + $part = ''; + $parts = []; + + // State machine + $mode = static::M_BARE; + $chars = mb_str_split($expr); + $char = NULL; + $signature = ''; + + // Utility to throw errors + $fail = function () use (&$char, &$chars, &$signature, $expr) { + $offset = mb_strlen($expr) - count($chars); + throw new \RuntimeException("Parse error on \"$char\" (at $offset, sig \"$signature\")"); + }; + + // Loop through chars. + while (count($chars)) { + $char = array_shift($chars); + $tokenType = $tokenTypes[$char] ?? static::T_CHAR; + $signature = $mode . $tokenType; + switch ($signature) { + case 'Bc': /* Bare mode, T_CHAR */ + case 'Sc': /* Single-quote mode, T_CHAR */ + case 'S ': /* Single-quote mode, T_SPACE */ + case 'S"': /* Single-quote mode, T_DBL */ + case 'Dc': /* Double-quote mode, T_CHAR */ + case 'D ': + case "D'": + $part .= $char; + break; + + case 'B ': + $parts[] = $part; + $part = ''; + break; + + case 'B\\': + case 'S\\': + case 'D\\': + if (count($chars)) { + $part .= array_shift($chars); + } + break; + + case "B'": + $mode = static::M_SNG; + break; + + case 'B"': + $mode = static::M_DBL; + break; + + case 'D"': + case "S'": + $mode = static::M_BARE; + break; + + default: + $fail(); + } + } + + if ($part !== '') { + $parts[] = $part; + } + + return $parts; + } + +} diff --git a/src/plugin/basename.php b/src/plugin/basename.php new file mode 100644 index 0000000..2f9cf7a --- /dev/null +++ b/src/plugin/basename.php @@ -0,0 +1,9 @@ +addListener('loco.expr.functions', function (LocoEvent $e) { + $e['functions']['basename'] = function ($expr) { + return basename($expr); + }; +}); diff --git a/src/plugin/dirname.php b/src/plugin/dirname.php new file mode 100644 index 0000000..3678719 --- /dev/null +++ b/src/plugin/dirname.php @@ -0,0 +1,9 @@ +addListener('loco.expr.functions', function (LocoEvent $e) { + $e['functions']['dirname'] = function ($expr) { + return dirname($expr); + }; +}); diff --git a/src/plugin/echo.php b/src/plugin/echo.php new file mode 100644 index 0000000..1e6aabc --- /dev/null +++ b/src/plugin/echo.php @@ -0,0 +1,9 @@ +addListener('loco.expr.functions', function (LocoEvent $e) { + $e['functions']['echo'] = function (...$argv) { + return implode(' ', $argv); + }; +}); diff --git a/tests/E2E/EnvComputationTest.php b/tests/E2E/EnvComputationTest.php index 4d66e49..a50ea0b 100644 --- a/tests/E2E/EnvComputationTest.php +++ b/tests/E2E/EnvComputationTest.php @@ -221,7 +221,7 @@ protected function computeEnvironment(array $args = [], array $putEnvs = []): ar $this->assertEquals(0, $tester->getStatusCode()); $out = $tester->getDisplay(); - $lines = explode("\n", $out); + $lines = explode("\n", trim($out)); $vars = []; foreach ($lines as $line) { [$key, $value] = explode('=', $line, 2); diff --git a/tests/LocoEnvTest.php b/tests/LocoEnvTest.php new file mode 100644 index 0000000..3e3f576 --- /dev/null +++ b/tests/LocoEnvTest.php @@ -0,0 +1,79 @@ + ["ab","cd"] + // This helps to write tests about the way parameters are passed. + $e['functions']['test_json'] = function(...$argv) { + return \json_encode($argv, \JSON_UNESCAPED_SLASHES); + }; + } + + protected function setUp(): void { + Loco::dispatcher()->addListener('loco.expr.functions', [__CLASS__, 'onRegisterFunctions']); + } + + protected function tearDown(): void { + Loco::dispatcher()->removeListener('loco.expr.functions', [__CLASS__, 'onRegisterFunctions']); + } + + public function getExamples() { + $es = []; + $es[] = ['rides a $COLOR bike', 'rides a red bike']; + $es[] = ['rides a ${COLOR} bike', 'rides a red bike']; + $es[] = ['go $TRANSPORT', 'go red bike']; + $es[] = ['Loaded $(basename $FILE)!', 'Loaded LocoEnvTest.php!']; + $es[] = ['Loaded $(basename ${FILE})!', 'Loaded LocoEnvTest.php!']; + $es[] = ['Loaded ($(basename $FILE))', 'Loaded (LocoEnvTest.php)']; + $es[] = ['Loaded {$(basename $FILE)}', 'Loaded {LocoEnvTest.php}']; + $es[] = ['Loaded from $(dirname $FILE)', 'Loaded from ' . __DIR__]; + $es[] = ['Loaded from $DIR', 'Loaded from ' . __DIR__]; + $es[] = ['Loaded from $GRAMPA', 'Loaded from ' . dirname(__DIR__)]; + $es[] = ['Move to $(basename $FILE)bak', 'Move to LocoEnvTest.phpbak']; + $es[] = ['Move to $(dirname $FILE)bak', 'Move to ' . __DIR__ . 'bak']; + $es[] = ['Copy $(basename $FILE) to $(basename $FILE.bak)', 'Copy LocoEnvTest.php to LocoEnvTest.php.bak']; + $es[] = ['more ${COLOR}ish', 'more redish']; + $es[] = ['more $COLORish', 'more ']; + $es[] = ['if{$COLOR}', 'if{red}']; + $es[] = ['if{${COLOR}}', 'if{red}']; + $es[] = ['if($COLOR)', 'if(red)']; + $es[] = ['1 + $NUM', '1 + 1234']; + $es[] = ['$SYMBOLOGY', 'the $COLOR of a $NUM']; + $es[] = ['$', '$']; + $es[] = ['()', '()']; + $es[] = ['{}', '{}']; + // $es[] = ['$()', '']; + $es[] = ['apple $(echo red $COLOR rouge) pomegranate', 'apple red red rouge pomegranate']; + $es[] = ['fruity $(echo "$COLOR apples" and "$COLOR pomegranates) for $(echo $NUM) people', 'fruity red apples and red pomegranates for 1234 people']; + $es[] = ['json data $(test_json "$TRANSPORT" "go go" "go $TRANSPORT")', 'json data ["red bike","go go","go red bike"]']; + $es[] = ['json data $(test_json $TRANSPORT "go go")', 'json data ["red bike","go go"]']; /* This is not necessarily good behavior. But when/if it changes, that should be clear. */ + return $es; + } + + /** + * @param string $input + * @param string $expect + * @return void + * @dataProvider getExamples + */ + public function testEvaluate(string $input, string $expect) { + $sys = LocoSystem::create(NULL, NULL, []); + $env = $sys->environment; + + $env->set('FILE', __FILE__); + $env->set('DIR', '$(dirname $FILE)', TRUE); + $env->set('GRAMPA', '$(dirname $DIR)', TRUE); + $env->set('COLOR', 'red'); + $env->set('NUM', '1234'); + $env->set('TRANSPORT', '$COLOR bike', TRUE); + $env->set('SYMBOLOGY', 'the $COLOR of a $NUM'); + + $this->assertEquals($expect, $env->evaluate($input, 'null'), "Evaluate \"$input\""); + } + +} diff --git a/tests/Utils/ShellStringTest.php b/tests/Utils/ShellStringTest.php new file mode 100644 index 0000000..5c26a23 --- /dev/null +++ b/tests/Utils/ShellStringTest.php @@ -0,0 +1,36 @@ +assertEquals($enc($expect), $enc($actual), "Split \"$input\""); + } + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1525bbb..f61e762 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,3 +18,5 @@ #### Extra - Register classes in "tests" directory $loader->addPsr4('Loco\\', __DIR__); + +\Loco\Loco::plugins()->init();