You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
527 lines
13 KiB
527 lines
13 KiB
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Cfnpp;
|
|
|
|
use App\Dom\Document;
|
|
use App\Dom\Node;
|
|
use App\Dom\NodeFunction;
|
|
use App\Dom\NodeFunctionValue;
|
|
use App\Engine\IOptions;
|
|
use App\Util\DependencyGraph;
|
|
|
|
/**
|
|
* Compiler that implements the cfnpp "language" and allows you to
|
|
* apply stacked documents on top of each other, transforming them.
|
|
*
|
|
* @author Adam Pippin <hello@adampippin.ca>
|
|
*/
|
|
class Compiler implements \App\Engine\ICompile
|
|
{
|
|
/**
|
|
* Functions that can be called by the document.
|
|
*
|
|
* Signature:
|
|
* callable(Node $node, Node $function)
|
|
*
|
|
* @var array<string, callable>
|
|
*/
|
|
protected $functions;
|
|
|
|
/**
|
|
* Functions that merge two documents, and will be
|
|
* called with a node from the current state and
|
|
* document being compiled.
|
|
*
|
|
* Signature:
|
|
* callable(Node $current_state, Node $new_document, Node $function)
|
|
*
|
|
* @var array<string, callable>
|
|
*/
|
|
protected $merge_functions;
|
|
|
|
/**
|
|
* Stores current state of the document so it can be mutated mid-pass.
|
|
*
|
|
* @var ?Document
|
|
*/
|
|
protected $document;
|
|
|
|
/**
|
|
* Register a function that can be called by the document.
|
|
*
|
|
* @param string $name
|
|
* @param callable $callback callback(Node $node, Node $function): ?Node
|
|
* @return void
|
|
*/
|
|
public function registerFunction(string $name, callable $callback): void
|
|
{
|
|
$this->functions[$name] = $callback;
|
|
}
|
|
|
|
/**
|
|
* Register a merge function that can be called by the document and passed
|
|
* both the current state and the new document.
|
|
*
|
|
* @param string $name
|
|
* @param callable $callback callback(Node $current, Node $new_doc, Node $function): ?Node
|
|
* @return void
|
|
*/
|
|
public function registerMergeFunction(string $name, callable $callback): void
|
|
{
|
|
$this->merge_functions[$name] = $callback;
|
|
}
|
|
|
|
/**
|
|
* Add a condition to the document currently being processed.
|
|
*
|
|
* The only appropriate place to call this is from the document functions
|
|
*
|
|
* @param string $name name of condition to create
|
|
* @param Node $node
|
|
* @return void
|
|
*/
|
|
public function addCondition(string $name, Node $node): void
|
|
{
|
|
$conditions = $this->document->getChildByName('Conditions');
|
|
if (!isset($conditions))
|
|
{
|
|
$conditions = new Node($this->document, 'Conditions');
|
|
$this->document->addChild($conditions);
|
|
}
|
|
|
|
$node->setName($name);
|
|
$conditions->addChild($node);
|
|
}
|
|
|
|
/**
|
|
* Compile a set of documents and return the result.
|
|
*
|
|
* @param Document[] $documents
|
|
* @param IOptions $options
|
|
* @return Document
|
|
*/
|
|
public function compile(array $documents, IOptions $options): Document
|
|
{
|
|
if (!($options instanceof Options))
|
|
{
|
|
throw new \Exception('Cfnpp\\Compiler requires Cfnpp\\Options');
|
|
}
|
|
|
|
// Initialize state
|
|
$this->document = null;
|
|
$this->functions = [];
|
|
$this->merge_functions = [];
|
|
|
|
// Register built-in functions
|
|
$cfnpp_functions = new Functions($this, $options);
|
|
$cfnpp_functions->register($this);
|
|
|
|
$this->document = $document = $this->pass_0($documents, $options);
|
|
$this->pass_1($document, $options);
|
|
$this->pass_2($document, $options);
|
|
|
|
return $this->document;
|
|
}
|
|
|
|
/**
|
|
* Compiler Pass 0 - Build a dependency graph, run mergeFunctions + merge
|
|
* for each as appropriate. Return the resulting document.
|
|
*
|
|
* @param Document[] $documents
|
|
* @param IOptions $options
|
|
* @return Document
|
|
*/
|
|
protected function pass_0(array $documents, IOptions $options): Document
|
|
{
|
|
|
|
// Build dependency graph
|
|
$graph = new DependencyGraph();
|
|
foreach ($documents as $doc)
|
|
{
|
|
$stack = [];
|
|
try
|
|
{
|
|
$stack = $doc->getMeta('stack');
|
|
}
|
|
catch (\Exception $ex)
|
|
{
|
|
}
|
|
$graph->add($doc->getDocumentName(), $stack);
|
|
}
|
|
|
|
$docs = $graph->solve();
|
|
|
|
// Reorder all our documents in whatever order the dependency solver
|
|
// says we should process them in.
|
|
// O(N^2), fix this if it ever actually becomes a problem.
|
|
$ordered_documents = [];
|
|
foreach ($docs as $doc_name)
|
|
{
|
|
foreach ($documents as $document)
|
|
{
|
|
if ($document->getDocumentName() == $doc_name)
|
|
{
|
|
$ordered_documents[] = $document;
|
|
continue 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run our merge + merge functions
|
|
$document = new Document();
|
|
foreach ($ordered_documents as $next_document)
|
|
{
|
|
$this->runMergeFunctions($document, $next_document);
|
|
$this->merge($document, $next_document);
|
|
}
|
|
|
|
// Throw some of our state into the document for posterity's sake
|
|
$prefix = $this->getShortestCommonPrefix($docs);
|
|
foreach ($docs as &$doc)
|
|
{
|
|
$doc = substr($doc, strlen($prefix));
|
|
}
|
|
unset($doc);
|
|
$document->setChildByPath('Metadata.Stack', Node::fromPhp($docs));
|
|
|
|
return $document;
|
|
}
|
|
|
|
/**
|
|
* Compiler Pass 1 - Grab all variables and parameters, build dependency
|
|
* graph of dependencies between values, resolve values. Then
|
|
* set them on $options to include them in program state.
|
|
*
|
|
* @param Document $document
|
|
* @param IOptions $options
|
|
* @return void
|
|
*/
|
|
protected function pass_1(Document $document, IOptions $options): void
|
|
{
|
|
$variables_node = $document->getChildByPath('cfnpp.variables');
|
|
$parameters_node = $document->getChildByPath('cfnpp.parameters');
|
|
// If there are no variables or parameters, we're done here.
|
|
if (!isset($variables_node) && !isset($parameters_node))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$nodes = [];
|
|
$graph = new DependencyGraph();
|
|
|
|
if (isset($parameters_node))
|
|
{
|
|
foreach ($parameters_node as $parameter_node)
|
|
{
|
|
$nodes[$parameter_node->getName()] = $parameter_node;
|
|
$graph->add($parameter_node->getName(), $this->pass_1_getDependencies($parameter_node));
|
|
}
|
|
}
|
|
|
|
if (isset($variables_node))
|
|
{
|
|
foreach ($variables_node as $variable_node)
|
|
{
|
|
if (isset($nodes[$variable_node->getName()]))
|
|
{
|
|
throw new \Exception('Variables and parameters cannot share the same name.');
|
|
}
|
|
$nodes[$variable_node->getName()] = $variable_node;
|
|
$graph->add($variable_node->getName(), $this->pass_1_getDependencies($variable_node));
|
|
}
|
|
}
|
|
|
|
$nodes_ordered = $graph->solve();
|
|
|
|
foreach ($nodes_ordered as $node_name)
|
|
{
|
|
$this->runFunctions($nodes[$node_name]);
|
|
|
|
$type = $nodes[$node_name]->getParent()->getName();
|
|
|
|
if ($type == 'parameters')
|
|
{
|
|
$parameter_node = $parameters_node->getChildByName($node_name);
|
|
if (isset($parameter_node))
|
|
{
|
|
$options->setParameter($parameter_node->getName(), Node::toPhp($parameter_node));
|
|
}
|
|
}
|
|
elseif ($type == 'variables')
|
|
{
|
|
$variable_node = $variables_node->getChildByName($node_name);
|
|
if (isset($variable_node))
|
|
{
|
|
$options->setVariable($variable_node->getName(), Node::toPhp($variable_node));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given a node tree, get a list of variables that are referenced in that
|
|
* tree.
|
|
*
|
|
* @todo whenever we add expressions this will have to be extended/rewritten
|
|
* @param Node $node
|
|
* @return string[]
|
|
*/
|
|
protected function pass_1_getDependencies(Node $node): array
|
|
{
|
|
$stack = [$node];
|
|
|
|
$variables = [];
|
|
|
|
while (sizeof($stack))
|
|
{
|
|
$node = array_shift($stack);
|
|
|
|
if ($node->hasChildren())
|
|
{
|
|
$stack = array_merge($stack, $node->getChildren());
|
|
}
|
|
|
|
if ($node instanceof NodeFunctionValue &&
|
|
$node->getName() == 'var')
|
|
{
|
|
$variables[] = $node->getValue();
|
|
}
|
|
elseif ($node instanceof NodeFunctionValue &&
|
|
$node->getName() == 'param')
|
|
{
|
|
$variables[] = $node->getValue();
|
|
}
|
|
elseif ($node instanceof NodeFunctionValue &&
|
|
$node->getName() == 'expr')
|
|
{
|
|
$expression = new \App\Cfnpp\Expression\Expression($node->getValue());
|
|
$variables = array_merge($variables, $expression->getReferencedVariables());
|
|
}
|
|
}
|
|
|
|
return $variables;
|
|
}
|
|
|
|
/**
|
|
* Compiler Pass 2 - Run all remaining functions in the resulting document,
|
|
* mutating the passed in document as required.
|
|
*
|
|
* @param Document $document
|
|
* @param IOptions $options
|
|
* @return void
|
|
*/
|
|
protected function pass_2(Document $document, IOptions $options): void
|
|
{
|
|
$this->runFunctions($document);
|
|
}
|
|
|
|
/**
|
|
* Performs a basic recursive merge of two dom trees with target overwriting
|
|
* original.
|
|
*
|
|
* - Maps will be merged
|
|
* - Arrays will be appended
|
|
* - Other values will be overwritten
|
|
* - If there's any mismatch (e.g., array in original + map in target), then
|
|
* everything's just replaced with whatever's in the target.
|
|
*
|
|
* The original DOM tree will represent the final state. The target tree will
|
|
* be clobbered, as many nodes are simply reparented into the original tree
|
|
* where appropriate.
|
|
*
|
|
* @param Node $original
|
|
* @param Node $target
|
|
* @return void
|
|
*/
|
|
protected function merge(Node $original, Node $target): void
|
|
{
|
|
if ($original->isArray() && $target->isArray())
|
|
{
|
|
foreach ($target as $child)
|
|
{
|
|
$original->addChild($child);
|
|
$child->setParent($original);
|
|
}
|
|
}
|
|
elseif ($original->isMap() && $target->isMap())
|
|
{
|
|
foreach ($target as $child)
|
|
{
|
|
$orig_child = $original->getChildByName($child->getName());
|
|
// If the key doesn't exist on the source, just copy over and we're done
|
|
if (!isset($orig_child))
|
|
{
|
|
$original->addChild($child);
|
|
$child->setParent($original);
|
|
continue;
|
|
}
|
|
|
|
// If this is a map or array, we need to descend into it
|
|
if ($child->isMap() || $child->isArray())
|
|
{
|
|
$this->merge($orig_child, $child);
|
|
}
|
|
// Otherwise just replace it (nodefunction, nodevalue, whatever)
|
|
else
|
|
{
|
|
$original->removeChild($orig_child);
|
|
$original->addChild($child);
|
|
$child->setParent($original);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// If it's anything else, overwrite
|
|
$original->remove();
|
|
$original->getParent()->addChild($target);
|
|
$target->setParent($original);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walks through two dom trees simultaneously, adds missing nodes from
|
|
* target to original, and executes any 'merge functions' it encounters
|
|
* along the way.
|
|
*
|
|
* @param Node $original
|
|
* @param Node $target
|
|
* @return void
|
|
*/
|
|
protected function runMergeFunctions(Node $original, Node $target): void
|
|
{
|
|
if ($target->isFunctionParent() && isset($this->merge_functions[$target[0]->getName()]))
|
|
{
|
|
$function_node = $target[0];
|
|
$result = $this->runMergeFunction($original, $target, $function_node);
|
|
|
|
$original->remove();
|
|
$target->remove();
|
|
|
|
if (isset($result))
|
|
{
|
|
$original_node = $result;
|
|
$original_node->setParent($original->getParent());
|
|
$original->getParent()->addChild($original_node);
|
|
$original = $original_node;
|
|
|
|
$target_node = clone $result;
|
|
$target_node->setParent($target->getParent());
|
|
$target->getParent()->addChild($target_node);
|
|
$target = $target_node;
|
|
}
|
|
}
|
|
|
|
foreach ($target as $node)
|
|
{
|
|
if ($node->hasName())
|
|
{
|
|
$orig_child = $original->getChildByName($node->getName());
|
|
if (!isset($orig_child))
|
|
{
|
|
$orig_child = new Node($original, $node->getName());
|
|
$original->addChild($orig_child);
|
|
}
|
|
|
|
$this->runMergeFunctions($orig_child, $node);
|
|
}
|
|
}
|
|
|
|
if (isset($result))
|
|
{
|
|
$target->remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walks through the DOM tree and executes any functions it encounters.
|
|
*
|
|
* @param Node $node
|
|
* @return void
|
|
*/
|
|
protected function runFunctions(Node $node): void
|
|
{
|
|
$children = $node->getChildren();
|
|
foreach ($children as $child)
|
|
{
|
|
$this->runFunctions($child);
|
|
}
|
|
|
|
if ($node->isFunctionParent() && isset($this->functions[$node[0]->getName()]))
|
|
{
|
|
$function_node = $node[0];
|
|
$result = $this->runFunction($node, $function_node);
|
|
|
|
$node->remove();
|
|
if (isset($result))
|
|
{
|
|
$node->getParent()->addChild($result);
|
|
$result->setParent($node->getParent());
|
|
$node = $result;
|
|
}
|
|
else
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the function referenced by the NodeFunction node, and returns the
|
|
* result.
|
|
*
|
|
* @param Node $node
|
|
* @param NodeFunction $function
|
|
* @return ?Node
|
|
*/
|
|
protected function runFunction(Node $node, NodeFunction $function): ?Node
|
|
{
|
|
return $this->functions[$function->getName()]($node, $function);
|
|
}
|
|
|
|
/**
|
|
* Runs a merge function referenced by the NodeFunction, and returns the
|
|
* result.
|
|
*
|
|
* @param Node $original
|
|
* @param Node $target
|
|
* @param NodeFunction $function
|
|
* @return ?Node
|
|
*/
|
|
protected function runMergeFunction(Node $original, Node $target, NodeFunction $function): ?Node
|
|
{
|
|
return $this->merge_functions[$function->getName()]($original, $target, $function);
|
|
}
|
|
|
|
/**
|
|
* Given a list of strings, find and return the shortest common prefix of all
|
|
* strings. Can return an empty string if there's no prefix in common.
|
|
*
|
|
* @param string[] $strings
|
|
* @return string
|
|
*/
|
|
protected function getShortestCommonPrefix(array $strings): string
|
|
{
|
|
// Find longest common prefix
|
|
$shortest_string_length = array_reduce($strings, static function(int $carry, string $item): int {
|
|
return min($carry, strlen($item));
|
|
}, PHP_INT_MAX);
|
|
|
|
for ($i = 0; $i < $shortest_string_length; $i++)
|
|
{
|
|
$c = $strings[0][$i];
|
|
for ($x = 1; $x < sizeof($strings); $x++)
|
|
{
|
|
if ($strings[$x][$i] != $c)
|
|
{
|
|
--$i;
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $i == 0 ? '' : substr($strings[0], 0, $i);
|
|
}
|
|
}
|
|
|