Skip to content

Commit

Permalink
Merge pull request #8 from totten/master-eval
Browse files Browse the repository at this point in the history
Improve support for inline function calls
  • Loading branch information
totten authored Jan 29, 2023
2 parents b2148d3 + 77377e1 commit 7244c13
Show file tree
Hide file tree
Showing 16 changed files with 406 additions and 48 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion doc/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
21 changes: 18 additions & 3 deletions doc/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- Why not just call out to bash for evaluation? You'd have to materialize the env-vars first. I don't quite have my finger on why, but this feels tricky. -->

If further computation is required, then use a [plugin](plugins.md) to define custom variables or custom functions.

## Specification: Initializing config files

Expand Down
56 changes: 20 additions & 36 deletions src/LocoEnv.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]);
Expand Down Expand Up @@ -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'])
Expand All @@ -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);
}

}
74 changes: 74 additions & 0 deletions src/LocoEvaluator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Loco;

use Loco\Utils\ShellString;

class LocoEvaluator {

/**
* @var callable[]|null
*/
protected $functions = NULL;

/**
* @param string|null $valExpr
* The user-supplied expression to evaluate.
* @param callable $lookupVar
* A function to lookup values of variables.
* @return string|null
* User-supplied expression, with variables replaced.
*/
public function evaluate(?string $valExpr, callable $lookupVar): ?string {
if (empty($valExpr)) {
return $valExpr;
}

$varExprRegex = '\$([a-zA-Z0-9_]+|{[a-zA-Z0-9_]+})'; // Ex: '$FOO' or '${FOO}'
$funcNameRegex = '[a-zA-Z-9_]+'; // Ex: 'basename' or 'dirname'
$funcExprRegex = '\$\((' . $funcNameRegex . ')([^()]*)\)'; // Ex: '$(basename $FOO)'

return preg_replace_callback(';(' . $funcExprRegex . '|' . $varExprRegex . ');', function($mainMatch) use ($valExpr, $varExprRegex, $funcExprRegex, $lookupVar) {
if (preg_match(";^$varExprRegex$;", $mainMatch[1], $matches)) {
$name = preg_replace(';^\{(.*)\}$;', '\1', $matches[1]);
return call_user_func($lookupVar, $name);
}
elseif (preg_match(";^$funcExprRegex$;", $mainMatch[1], $matches)) {
$func = $matches[1];
$rawArgs = ShellString::split(trim($matches[2]));
$args = [];
foreach ($rawArgs as $rawArg) {
$args[] = $this->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);
}
}

}
3 changes: 3 additions & 0 deletions src/LocoPlugins.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions src/LocoService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]) : [];
}
Expand Down
14 changes: 11 additions & 3 deletions src/LocoSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class LocoSystem {
*/
public $format;

/**
* @var LocoEvaluator
*/
public $evaluator;

/**
* Default values for environment variables (defined at system-level in loco.yml).
*
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading

0 comments on commit 7244c13

Please sign in to comment.