*/ class Compiler implements \App\Engine\ICompile { /** * Functions that can be called by the document. * * Signature: * callable(Node $node, Node $function) * * @var array */ 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 */ 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, $options)); } } 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, $options)); } } $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, IOptions $options): 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); } }