/
home
/
obinna
/
html
/
boaz2
/
vendor
/
symfony
/
maker-bundle
/
src
/
Util
/
Upload File
HOME
<?php /* * This file is part of the Symfony MakerBundle package. * * (c) Fabien Potencier <fabien@symfony.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Bundle\MakerBundle\Util; use Psr\Log\LoggerInterface; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; /** * A class that modifies YAML source, while keeping comments & formatting. * * This is designed to work for the most common syntaxes, but not * all YAML syntaxes. If content cannot be updated safely, an * exception is thrown. */ class YamlSourceManipulator { public const EMPTY_LINE_PLACEHOLDER_VALUE = '__EMPTY_LINE__'; public const COMMENT_PLACEHOLDER_VALUE = '__COMMENT__'; public const UNSET_KEY_FLAG = '__MAKER_VALUE_UNSET'; public const ARRAY_FORMAT_MULTILINE = 'multi'; public const ARRAY_FORMAT_INLINE = 'inline'; public const ARRAY_TYPE_SEQUENCE = 'sequence'; public const ARRAY_TYPE_HASH = 'hash'; /** * @var LoggerInterface|null */ private $logger; private $contents; private $currentData; private $currentPosition = 0; private $previousPath = []; private $currentPath = []; private $depth = 0; private $indentationForDepths = []; private $arrayFormatForDepths = []; private $arrayTypeForDepths = []; public function __construct(string $contents) { $this->contents = $contents; $this->currentData = Yaml::parse($contents); if (!\is_array($this->currentData)) { throw new \InvalidArgumentException('Only YAML with a top-level array structure is supported'); } } public function setLogger(LoggerInterface $logger) { $this->logger = $logger; } public function getData(): array { return $this->currentData; } public function getContents(): string { return $this->contents; } public function setData(array $newData) { $this->currentPath = []; $this->previousPath = []; $this->currentPosition = 0; $this->depth = -1; $this->indentationForDepths = []; $this->arrayFormatForDepths = []; $this->arrayTypeForDepths = []; $this->updateData($newData); $this->replaceSpecialMetadataCharacters(); // update the data now that the special chars have been removed $this->currentData = Yaml::parse($this->contents); // remove special metadata keys that were replaced $newData = $this->removeMetadataKeys($newData); // Before comparing, re-index any sequences on the new data. // The current data will already use sequential indexes $newData = $this->normalizeSequences($newData); if ($newData !== $this->currentData) { throw new YamlManipulationFailedException(sprintf('Failed updating YAML contents: the process was successful, but something was not updated. Expected new data: %s. Actual new data: %s', var_export($newData, true), var_export($this->currentData, true))); } } public function createEmptyLine(): string { return self::EMPTY_LINE_PLACEHOLDER_VALUE; } public function createCommentLine(string $comment): string { return self::COMMENT_PLACEHOLDER_VALUE.$comment; } private function updateData(array $newData) { ++$this->depth; if (0 === $this->depth) { $this->indentationForDepths[$this->depth] = 0; $this->arrayFormatForDepths[$this->depth] = self::ARRAY_FORMAT_MULTILINE; } else { // match the current indentation to start $this->indentationForDepths[$this->depth] = $this->indentationForDepths[$this->depth - 1]; // advancing is especially important if this is an inline array: // get into the [] or {} $this->arrayFormatForDepths[$this->depth] = $this->guessNextArrayTypeAndAdvance(); } $currentData = $this->getCurrentData(); $this->arrayTypeForDepths[$this->depth] = $this->isHash($currentData) ? self::ARRAY_TYPE_HASH : self::ARRAY_TYPE_SEQUENCE; $this->log(sprintf( 'Changing array type & format via updateData() (type=%s, format=%s)', $this->arrayTypeForDepths[$this->depth], $this->arrayFormatForDepths[$this->depth] )); foreach ($currentData as $key => $currentVal) { // path setting is mostly duplicated at the bottom of this method $this->previousPath = $this->currentPath; if (!isset($this->previousPath[$this->depth])) { // if there is no previous flag at this level, mark it with a null $this->previousPath[$this->depth] = null; } $this->currentPath[$this->depth] = $key; // advance from the end of the previous value to the // start of the key, which may include whitespace or, for // example, some closing array syntax - } or ] - from the // previous value $this->advanceBeyondEndOfPreviousKey($key); $this->log('START key', true); // 1) was this key removed from the new data? if (!\array_key_exists($key, $newData)) { $this->log('Removing key'); $this->removeKeyFromYaml($key, $currentData[$key]); // manually update our current data now that the key is gone unset($currentData[$key]); // because the item was removed, reset the current path // to the previous path, so the next iteration doesn't // expect the previous path to have this removed key $this->currentPath = $this->previousPath; continue; } /* * 2) are there new keys in the new data before this key? * * To determine this, we look at the position of the key inside the * current data and compare it to the position of that same key in * the new data. While they are not equal, we loop. Inside the loop, * the new key is added to the current data *before* $key. Thanks to * this, on each loop, the currentDataIndex will increase until it * matches the new data */ while (($currentDataIndex = array_search($key, array_keys($currentData))) !== array_search($key, array_keys($newData))) { // loop until the current key is found at the same position in current & new data $newKey = array_keys($newData)[$currentDataIndex]; $newVal = $newData[$newKey]; $this->log('Adding new key: '.$newKey); $this->addNewKeyToYaml($newKey, $newVal); // refresh the current array data because we added an item // we can't just add the key manually, as it may have been // we can't just add the key manually, as it may have been // added in the middle $currentData = $this->getCurrentData(1); } // 3) Key already exists in YAML // advance the position to the end of this key $this->advanceBeyondKey($key); $newVal = $newData[$key]; // if the current data is an array, we should keep // walking through that data, even if it didn't change, // so that we can advance the current position if (\is_array($currentData[$key]) && \is_array($newVal)) { $this->log('Calling updateData() on next level'); $this->updateData($newVal); continue; } // 3a) value did NOT change if ($currentData[$key] === $newVal) { $this->log('value did not change'); $this->advanceBeyondValue($newVal); continue; } // 3b) value DID change $this->log(sprintf('updating value to {%s}', \is_array($newVal) ? '<array>' : $newVal)); $this->changeValueInYaml($newVal); } // Bonus! are there new keys in the data after this key... // and this is the final key? // Edge case: if the last item on a multi-line array has a comment, // we want to move to the end of the line, beyond that comment if (\count($currentData) < \count($newData) && $this->isCurrentArrayMultiline()) { $this->advanceBeyondMultilineArrayLastItem($currentData, $newData); } if (0 === $this->indentationForDepths[$this->depth] && $this->depth > 1) { $ident = $this->getPreferredIndentationSize(); $previousDepth = $this->depth - 1; $this->indentationForDepths[$this->depth] = ($ident + $this->indentationForDepths[$previousDepth]); } while (\count($currentData) < \count($newData)) { $newKey = array_keys($newData)[\count($currentData)]; // manually move the paths forward // mostly duplicated above $this->previousPath = $this->currentPath; if (!isset($this->previousPath[$this->depth])) { // if there is no previous flag at this level, mark it with a null $this->previousPath[$this->depth] = null; } $this->currentPath[$this->depth] = $newKey; $newVal = $newData[$newKey]; $this->log('Adding new key to end of array'); $this->addNewKeyToYaml($newKey, $newVal); // refresh manually so the while sees it above $currentData = $this->getCurrentData(1); } $this->decrementDepth(); } /** * Adds a new key to current position in the YAML. * * The position should be set *right* where this new key * should be inserted. * * @param mixed $key * @param mixed $value */ private function addNewKeyToYaml($key, $value) { $extraOffset = 0; $firstItemInArray = false; if (empty($this->getCurrentData(1))) { // The array that we're appending is empty: // First, fix the "type" - it could be changing from a sequence to a hash or vice versa $this->arrayTypeForDepths[$this->depth] = \is_int($key) ? self::ARRAY_TYPE_SEQUENCE : self::ARRAY_TYPE_HASH; // we prefer multi-line, so let's convert to it! $this->arrayFormatForDepths[$this->depth] = self::ARRAY_FORMAT_MULTILINE; // if this is an inline empty array (is there any other), we need to // remove the empty array characters = {} or [] // we are already 1 character beyond the starting { or [ - so, rewind before it --$this->currentPosition; // now, rewind any spaces to get back to the : after the key while (' ' === substr($this->contents, $this->currentPosition - 1, 1)) { --$this->currentPosition; } // determine an extra offset to "skip" when reconstructing the string $endingArrayPosition = $this->findPositionOfNextCharacter(['}', ']']); $extraOffset = $endingArrayPosition - $this->currentPosition; // increase the indentation of *this* level $this->manuallyIncrementIndentation(); $firstItemInArray = true; } elseif ($this->isPositionAtBeginningOfArray()) { $firstItemInArray = true; // the array is not empty, but we are prepending an element if ($this->isCurrentArrayMultiline()) { // indentation will be set to low, except for root level if ($this->depth > 0) { $this->manuallyIncrementIndentation(); } } else { // we're at the start of an inline array // advance beyond any whitespace so that our new key // uses the same whitespace that was originally after // the { or [ $this->advanceBeyondWhitespace(); } } if (\is_int($key)) { if ($this->isCurrentArrayMultiline()) { if ($this->isCurrentArraySequence()) { $newYamlValue = '- '.$this->convertToYaml($value); } else { // this is an associative array, but an indexed key // is being added. We can't use the "- " format $newYamlValue = sprintf( '%s: %s', $key, $this->convertToYaml($value) ); } } else { $newYamlValue = $this->convertToYaml($value); } } else { $newYamlValue = $this->convertToYaml([$key => $value]); } if (0 === $this->currentPosition) { // if we're at the beginning of the file, the situation is special: // no previous blank line is needed, but we DO need to add a blank // line after, because the remainder of the content expects the // current position the start at the beginning of a new line $newYamlValue = $newYamlValue."\n"; } else { if ($this->isCurrentArrayMultiline()) { // because we're inside a multi-line array, put this item // onto the *next* line & indent it $newYamlValue = "\n".$this->indentMultilineYamlArray($newYamlValue); } else { if ($firstItemInArray) { // avoid the starting "," if first item in array // but, DO add an ending "," $newYamlValue = $newYamlValue.', '; } else { $newYamlValue = ', '.$newYamlValue; } } } $newContents = substr($this->contents, 0, $this->currentPosition) .$newYamlValue .substr($this->contents, $this->currentPosition + $extraOffset); // manually bump the position: we didn't really move forward // any in the existing string, we just added our own new content $this->currentPosition = $this->currentPosition + \strlen($newYamlValue); if (0 === $this->depth) { $newData = $this->currentData; $newData = $this->appendToArrayAtCurrentPath($key, $value, $newData); } else { // first, append to the "local" array: the little array we're currently working on $newLocalData = $this->getCurrentData(1); $newLocalData = $this->appendToArrayAtCurrentPath($key, $value, $newLocalData); // second, set this new array inside the full data $newData = $this->currentData; $newData = $this->setValueAtCurrentPath($newLocalData, $newData, 1); } $this->updateContents( $newContents, $newData, $this->currentPosition ); } private function removeKeyFromYaml($key, $currentVal) { $endKeyPosition = $this->getEndOfKeyPosition($key); $endKeyPosition = $this->findEndPositionOfValue($currentVal, $endKeyPosition); if ($this->isCurrentArrayMultiline()) { $nextNewLine = $this->findNextLineBreak($endKeyPosition); // it's possible we're at the end of the file so there are no more \n if (false !== $nextNewLine) { $endKeyPosition = $nextNewLine; } } else { // find next ending character - , } or ] while (!\in_array($currentChar = substr($this->contents, $endKeyPosition, 1), [',', ']', '}'])) { ++$endKeyPosition; } // if a sequence or hash is ending, and the character before it is a space, keep that if ((']' === $currentChar || '}' === $currentChar) && ' ' === substr($this->contents, $endKeyPosition - 1, 1)) { --$endKeyPosition; } } $newPositionBump = 0; $extraContent = ''; if (1 === \count($this->getCurrentData(1))) { // the key being removed is the *only* key // we need to close the new, empty array $extraContent = ' []'; // when processing arrays normally, the position is set // after the opening character. Move this here manually $newPositionBump = 2; // if it *was* multiline, the indentation is now lost if ($this->isCurrentArrayMultiline()) { $this->indentationForDepths[$this->depth] = $this->indentationForDepths[$this->depth - 1]; } // it is now definitely a sequence $this->arrayTypeForDepths[$this->depth] = self::ARRAY_TYPE_SEQUENCE; // it is now inline $this->arrayFormatForDepths[$this->depth] = self::ARRAY_FORMAT_INLINE; } $newContents = substr($this->contents, 0, $this->currentPosition) .$extraContent .substr($this->contents, $endKeyPosition); $newData = $this->currentData; $newData = $this->removeKeyAtCurrentPath($newData); // instead of passing the new +2 position below, we do it here // manually. This is because this it's not a real position move, // we manually (above) added some new chars that didn't exist before $this->currentPosition = $this->currentPosition + $newPositionBump; $this->updateContents( $newContents, $newData, // position is unchanged: just some content was removed $this->currentPosition ); } /** * Replaces the value at the current position with this value. * * The position should be set right at the start of this value * (i.e. after its key). * * @param mixed $value The new value to set into YAML */ private function changeValueInYaml($value) { $originalVal = $this->getCurrentData(); $endValuePosition = $this->findEndPositionOfValue($originalVal); $isMultilineValue = null !== $this->findPositionOfMultilineCharInLine($this->currentPosition); // In case of multiline, $value is converted as plain string like "Foo\nBar" // We need to keep it "as is" $newYamlValue = $isMultilineValue ? rtrim($value, "\n") : $this->convertToYaml($value); if ((!\is_array($originalVal) && \is_array($value)) || ($this->isMultilineString($originalVal) && $this->isMultilineString($value)) ) { // we're converting from a scalar to a (multiline) array // this means we need to break onto the next line // increase(override) the indentation $newYamlValue = "\n".$this->indentMultilineYamlArray($newYamlValue, $this->indentationForDepths[$this->depth] + $this->getPreferredIndentationSize()); } elseif ($this->isCurrentArrayMultiline() && $this->isCurrentArraySequence()) { // we are a multi-line sequence, so drop to next line, indent and add "- " in front $newYamlValue = "\n".$this->indentMultilineYamlArray('- '.$newYamlValue); } else { // empty space between key & value $newYamlValue = ' '.$newYamlValue; } $newPosition = $this->currentPosition + \strlen($newYamlValue); $isNextContentComment = $this->isPreviousLineComment($newPosition); if ($isNextContentComment) { ++$newPosition; } if ($isMultilineValue) { // strlen(" |") $newPosition -= 2; } $newContents = substr($this->contents, 0, $this->currentPosition) .($isMultilineValue ? ' |' : '') .$newYamlValue /* * If the next line is a comment, this means we probably had * a structure that looks like this: * access_control: * # - { path: ^/admin, roles: ROLE_ADMIN } * * In this odd case, we need to know that the next line * is a comment, so we can add an extra line break. * Otherwise, the result is something like: * access_control: * - { path: /foo, roles: ROLE_USER } # - { path: ^/admin, roles: ROLE_ADMIN } */ .($isNextContentComment ? "\n" : '') .substr($this->contents, $endValuePosition); $newData = $this->currentData; $newData = $this->setValueAtCurrentPath($value, $newData); $this->updateContents( $newContents, $newData, $newPosition ); } private function advanceBeyondKey($key) { $this->log(sprintf('Advancing position beyond key "%s"', $key)); $this->advanceCurrentPosition($this->getEndOfKeyPosition($key)); } private function advanceBeyondEndOfPreviousKey($key) { $this->log('Advancing position beyond PREV key'); $this->advanceCurrentPosition($this->getEndOfPreviousKeyPosition($key)); } private function advanceBeyondMultilineArrayLastItem(array $currentData, array $newData) { $this->log('Trying to advance beyond the last item in a multiline array'); $this->advanceBeyondWhitespace(); if ('#' === substr($this->contents, $this->currentPosition, 1)) { $this->log('The line ends with a comment, going to EOL'); $this->advanceToEndOfLine(); return; } $nextLineBreak = $this->findNextLineBreak($this->currentPosition); if ('}' === trim(substr($this->contents, $this->currentPosition, $nextLineBreak - $this->currentPosition))) { $this->log('The line ends with an array closing brace, going to EOL'); $this->advanceToEndOfLine(); } } private function advanceBeyondValue($value) { if (\is_array($value)) { throw new \LogicException('Do not pass an array to this method'); } $this->log(sprintf('Advancing position beyond value "%s"', $value)); $this->advanceCurrentPosition($this->findEndPositionOfValue($value)); } private function getEndOfKeyPosition($key) { preg_match($this->getKeyRegex($key), $this->contents, $matches, \PREG_OFFSET_CAPTURE, $this->currentPosition); if (empty($matches)) { // for integers, the key may not be explicitly printed if (\is_int($key)) { return $this->currentPosition; } throw new YamlManipulationFailedException(sprintf('Cannot find the key "%s"', $key)); } return $matches[0][1] + \strlen($matches[0][0]); } /** * Finds the end position of the key that comes *before* this key. */ private function getEndOfPreviousKeyPosition($key): int { preg_match($this->getKeyRegex($key), $this->contents, $matches, \PREG_OFFSET_CAPTURE, $this->currentPosition); if (empty($matches)) { // for integers, the key may not be explicitly printed if (\is_int($key)) { return $this->currentPosition; } $cursor = $this->currentPosition; while ('-' !== substr($this->contents, $cursor - 1, 1) && -1 !== $cursor) { --$cursor; } if ($cursor >= 0) { return $cursor; } throw new YamlManipulationFailedException(sprintf('Cannot find the key "%s"', $key)); } $startOfKey = $matches[0][1]; // if we're at the start of the file, we're done! if (0 === $startOfKey) { return 0; } /* * Now, walk backwards: so that the position is before any * whitespace, commas or line breaks. Basically, we want to go * back to the first character *after* the previous key started. */ // walk back any spaces while (' ' === substr($this->contents, $startOfKey - 1, 1)) { --$startOfKey; } // find either a line break or a , that is the end of the previous key while (\in_array($char = substr($this->contents, $startOfKey - 1, 1), [',', "\n"])) { --$startOfKey; } // look for \r\n if ("\r" === substr($this->contents, $startOfKey - 1, 1)) { --$startOfKey; } // if we're at the start of a line, if the prev line is a comment, move before it if ($this->isCharLineBreak(substr($this->contents, $startOfKey, 1))) { // move one (or two) forward so the code below finds the *previous* line ++$startOfKey; if ($this->isCharLineBreak(substr($this->contents, $startOfKey, 1))) { ++$startOfKey; } /* * In a multi-line array, the previous line(s) could be 100% comments. * In that situation, we want to rewind to *before* the comments, so * that those comments are attached to the current key and move with it. */ while ($this->isPreviousLineComment($startOfKey)) { --$startOfKey; // if this is a \n\r, we need to go back an extra char if ("\r" === substr($this->contents, $startOfKey - 1, 1)) { --$startOfKey; } while (!$this->isCharLineBreak(substr($this->contents, $startOfKey - 1, 1))) { --$startOfKey; // we've reached the start of the file! if (0 === $startOfKey) { break; } } } if (0 !== $startOfKey) { // move backwards one onto the previous line --$startOfKey; } // look for \n\r situation if ("\r" === substr($this->contents, $startOfKey - 1, 1)) { --$startOfKey; } } return $startOfKey; } private function getKeyRegex($key) { return sprintf('#(?<!\w)\$?%s\'?( )*:#', preg_quote($key)); } private function updateContents(string $newContents, array $newData, int $newPosition) { $this->log('updateContents()'); // validate the data try { $parsedContentsData = Yaml::parse($newContents); // normalize indexes on sequences to avoid comparison problems $parsedContentsData = $this->normalizeSequences($parsedContentsData); $newData = $this->normalizeSequences($newData); if ($parsedContentsData !== $newData) { throw new YamlManipulationFailedException(sprintf('Content was updated, but updated content does not match expected data. Original source: "%s", updated source: "%s", updated data: %s', $this->contents, $newContents, var_export($newData, true))); } } catch (ParseException $e) { throw new YamlManipulationFailedException(sprintf('Could not update YAML: a parse error occurred in the new content: "%s"', $newContents)); } // must be called before changing the contents $this->advanceCurrentPosition($newPosition); $this->contents = $newContents; $this->currentData = $newData; } private function convertToYaml($data) { $indent = $this->depth > 0 && isset($this->indentationForDepths[$this->depth]) ? intdiv($this->indentationForDepths[$this->depth], $this->depth) : 4; $newDataString = Yaml::dump($data, 4, $indent); // new line is appended: remove it $newDataString = rtrim($newDataString, "\n"); return $newDataString; } /** * Adds a new item (with the given key) to the $data array at the correct position. * * The $data should be the simple array that should be updated and that * the current path is pointing to. The current path is used * to determine *where* in the array to put the new item (so that it's * placed in the middle when necessary). */ private function appendToArrayAtCurrentPath($key, $value, array $data): array { if ($this->isPositionAtBeginningOfArray()) { // this should be prepended return [$key => $value] + $data; } $offset = array_search($this->previousPath[$this->depth], array_keys($data)); // if the target is currently the end of the array, just append if ($offset === (\count($data) - 1)) { $data[$key] = $value; return $data; } return array_merge( \array_slice($data, 0, $offset + 1), [$key => $value], \array_slice($data, $offset + 1, null) ); } private function setValueAtCurrentPath($value, array $data, int $limitLevels = 0) { // create a reference $dataRef = &$data; // start depth at $limitLevels (instead of 0) to properly detect when to set the key $depth = $limitLevels; $path = \array_slice($this->currentPath, 0, \count($this->currentPath) - $limitLevels); foreach ($path as $key) { if (!\array_key_exists($key, $dataRef)) { throw new \LogicException(sprintf('Could not find the key "%s" from the current path "%s" in data "%s"', $key, implode(', ', $path), var_export($data, true))); } if ($depth === $this->depth) { // we're at the correct depth! if (self::UNSET_KEY_FLAG === $value) { unset($dataRef[$key]); // if this is a sequence, reindex the keys if ($this->isCurrentArraySequence()) { $dataRef = array_values($dataRef); } } else { $dataRef[$key] = $value; } return $data; } // get a deeper reference $dataRef = &$dataRef[$key]; ++$depth; } throw new \LogicException('The value was not updated.'); } private function removeKeyAtCurrentPath(array $data): array { return $this->setValueAtCurrentPath(self::UNSET_KEY_FLAG, $data); } /** * Returns the value in the current data that is currently * being looked at. * * This could fail if the currentPath is for new data. * * @param int $limitLevels If set to 1, the data 1 level up will be returned * * @return mixed */ private function getCurrentData(int $limitLevels = 0) { $data = $this->currentData; $path = \array_slice($this->currentPath, 0, \count($this->currentPath) - $limitLevels); foreach ($path as $key) { if (!\array_key_exists($key, $data)) { throw new \LogicException(sprintf('Could not find the key "%s" from the current path "%s" in data "%s"', $key, implode(', ', $path), var_export($this->currentData, true))); } $data = $data[$key]; } return $data; } private function findEndPositionOfValue($value, $offset = null) { if (\is_array($value)) { $currentPosition = $this->currentPosition; $this->log('Walking across array to find end position of array'); $this->updateData($value); $endKeyPosition = $this->currentPosition; $this->currentPosition = $currentPosition; return $endKeyPosition; } if (\is_scalar($value) || null === $value) { $offset = null === $offset ? $this->currentPosition : $offset; if (\is_bool($value)) { // (?i) & (?-i) opens/closes case insensitive match $pattern = sprintf('(?i)%s(?-i)', $value ? 'true' : 'false'); } elseif (null === $value) { $pattern = '(~|NULL|null|\n)'; } else { // Multiline value ends with \n. // If we remove this character, the next property will ne merged with this value $quotedValue = preg_quote(rtrim($value, "\n"), '#'); $patternValue = $quotedValue; // Iterates until we find a new line char or we reach end of file if (null !== $this->findPositionOfMultilineCharInLine($offset)) { $patternValue = str_replace(["\r\n", "\n"], '\r?\n\s*', $quotedValue); } $pattern = sprintf('\'?"?%s\'?"?', $patternValue); } // a value like "foo:" can simply end a file // this means the value is null if ($offset === \strlen($this->contents)) { return $offset; } preg_match(sprintf('#%s#', $pattern), $this->contents, $matches, \PREG_OFFSET_CAPTURE, $offset); if (empty($matches)) { throw new YamlManipulationFailedException(sprintf('Cannot find the original value "%s"', $value)); } $position = $matches[0][1] + \strlen($matches[0][0]); // edge case where there is a comment between the current position // and the value we're looking for AND that comment contains an // exact string match for the value we're looking for if ($this->isFinalLineComment(substr($this->contents, $this->currentPosition, $position - $this->currentPosition))) { return $this->findEndPositionOfValue($value, $position); } if (null === $value && "\n" === $matches[0][0] && !$this->isCurrentLineComment($position)) { $this->log('Zero-length null value, next line not a comment, take a step back'); --$position; } return $position; } // there are other possible values, but we don't support them throw new YamlManipulationFailedException(sprintf('Unsupported Yaml value of type "%s"', \gettype($value))); } private function advanceCurrentPosition(int $newPosition) { $this->log(sprintf('advanceCurrentPosition() from %d to %d', $this->currentPosition, $newPosition), true); $originalPosition = $this->currentPosition; $this->currentPosition = $newPosition; // if we're not changing, or moving backwards, don't count indent // changes if ($newPosition <= $originalPosition) { return; } /* * A bit of a workaround. At times, this function will be called when the * position is at the beginning of the line: so, one character *after* * a line break. In that case, if there are a group of spaces at the * beginning of this first line, they *should* be used to calculate the new * indentation. To force this, if we detect this situation, we move one * character backwards, so that the first line is considered a valid line * to look for indentation. */ if ($this->isCharLineBreak(substr($this->contents, $originalPosition - 1, 1))) { --$originalPosition; } // look for empty lines and track the current indentation $advancedContent = substr($this->contents, $originalPosition, $newPosition - $originalPosition); $previousIndentation = $this->indentationForDepths[$this->depth]; $newIndentation = $previousIndentation; if ("\n" === $advancedContent) { $this->log('Just a linebreak, no indent changes'); return; } if (false !== strpos($advancedContent, "\n")) { $lines = explode("\n", $advancedContent); if (!empty($lines)) { $lastLine = $lines[\count($lines) - 1]; $lastLine = trim($lastLine, "\r"); $indentation = 0; while (' ' === substr($lastLine, $indentation, 1)) { ++$indentation; } $newIndentation = $indentation; } } $this->log(sprintf('Calculating new indentation: changing from %d to %d', $this->indentationForDepths[$this->depth], $newIndentation), true); $this->indentationForDepths[$this->depth] = $newIndentation; } private function decrementDepth() { $this->log('Moving up 1 level of depth'); unset($this->indentationForDepths[$this->depth]); unset($this->arrayFormatForDepths[$this->depth]); unset($this->arrayTypeForDepths[$this->depth]); unset($this->currentPath[$this->depth]); unset($this->previousPath[$this->depth]); --$this->depth; } private function getCurrentIndentation(int $override = null): string { $indent = $override ?? $this->indentationForDepths[$this->depth]; return str_repeat(' ', $indent); } private function log(string $message, $includeContent = false) { if (null === $this->logger) { return; } $context = [ 'key' => isset($this->currentPath[$this->depth]) ? $this->currentPath[$this->depth] : 'n/a', 'depth' => $this->depth, 'position' => $this->currentPosition, 'indentation' => $this->indentationForDepths[$this->depth], 'type' => $this->arrayTypeForDepths[$this->depth], 'format' => $this->arrayFormatForDepths[$this->depth], ]; if ($includeContent) { $context['content'] = sprintf( '>%s<', str_replace(["\r\n", "\n"], ['\r\n', '\n'], substr($this->contents, $this->currentPosition, 50)) ); } $this->logger->debug($message, $context); } private function isCurrentArrayMultiline(): bool { return self::ARRAY_FORMAT_MULTILINE === $this->arrayFormatForDepths[$this->depth]; } private function isCurrentArraySequence(): bool { return self::ARRAY_TYPE_SEQUENCE === $this->arrayTypeForDepths[$this->depth]; } /** * Attempts to guess if the array at the current position * is a multi-line array or an inline array. */ private function guessNextArrayTypeAndAdvance(): string { while (true) { if ($this->isEOF()) { throw new \LogicException('Could not determine array type'); } // get the next char & advance immediately $nextCharacter = substr($this->contents, $this->currentPosition, 1); // advance, but without advanceCurrentPosition() // because we are either moving along one line until [ { // or we are finding a line break and stopping: indentation // should not be calculated ++$this->currentPosition; if ($this->isCharLineBreak($nextCharacter)) { return self::ARRAY_FORMAT_MULTILINE; } if ('[' === $nextCharacter || '{' === $nextCharacter) { return self::ARRAY_FORMAT_INLINE; } } } /** * Advance until you find *one* of the characters in $chars. */ private function findPositionOfNextCharacter(array $chars) { $currentPosition = $this->currentPosition; while (true) { if ($this->isEOF($currentPosition)) { throw new \LogicException(sprintf('Could not find any characters: %s', implode(', ', $chars))); } // get the next char & advance immediately $nextCharacter = substr($this->contents, $currentPosition, 1); ++$currentPosition; if (\in_array($nextCharacter, $chars)) { return $currentPosition; } } } private function advanceBeyondWhitespace() { while (' ' === substr($this->contents, $this->currentPosition, 1)) { if ($this->isEOF()) { return; } ++$this->currentPosition; } } private function advanceToEndOfLine() { $newPosition = $this->currentPosition; while (!$this->isCharLineBreak(substr($this->contents, $newPosition, 1))) { if ($this->isEOF($newPosition)) { // found the end of the file! break; } ++$newPosition; } $this->advanceCurrentPosition($newPosition); } /** * Duplicated from Symfony's Inline::isHash(). * * Returns true if the value must be rendered as a hash, * which includes an indexed array, if the indexes are * not sequential. */ private function isHash($value): bool { if ($value instanceof \stdClass || $value instanceof \ArrayObject) { return true; } $expectedKey = 0; foreach ($value as $key => $val) { if ($key !== $expectedKey++) { return true; } } return false; } private function normalizeSequences(array $data) { // https://stackoverflow.com/questions/173400/how-to-check-if-php-array-is-associative-or-sequential/4254008#4254008 $hasStringKeys = function (array $array) { return \count(array_filter(array_keys($array), 'is_string')) > 0; }; foreach ($data as $key => $val) { if (!\is_array($val)) { continue; } if (!$hasStringKeys($val)) { // avoid indexed arrays with non-sequential keys // e.g. if a key was removed. This causes comparison issues $val = array_values($val); $data[$key] = $val; } $data[$key] = $this->normalizeSequences($val); } return $data; } private function removeMetadataKeys(array $data) { foreach ($data as $key => $val) { if (\is_array($val)) { $data[$key] = $this->removeMetadataKeys($val); continue; } if (self::EMPTY_LINE_PLACEHOLDER_VALUE === $val) { unset($data[$key]); } if (null !== $val && 0 === strpos($val, self::COMMENT_PLACEHOLDER_VALUE)) { unset($data[$key]); } } return $data; } private function replaceSpecialMetadataCharacters() { while (preg_match('#\n.*'.self::EMPTY_LINE_PLACEHOLDER_VALUE.'.*\n#', $this->contents, $matches)) { $this->contents = str_replace($matches[0], "\n\n", $this->contents); } while (preg_match('#\n(\s*).*\''.self::COMMENT_PLACEHOLDER_VALUE.'(.*)\'#', $this->contents, $matches)) { $fullMatch = $matches[0]; $indentation = $matches[1]; $comment = $matches[2]; $this->contents = str_replace( $fullMatch, sprintf("\n%s#%s", $indentation, $comment), $this->contents ); } } /** * Try to guess a preferred indentation level. */ private function getPreferredIndentationSize(): int { return isset($this->indentationForDepths[1]) && $this->indentationForDepths[1] > 0 ? $this->indentationForDepths[1] : 4; } /** * For the array currently being processed, are we currently * handling the first key inside of it? */ private function isPositionAtBeginningOfArray(): bool { return null === $this->previousPath[$this->depth]; } private function manuallyIncrementIndentation() { $this->indentationForDepths[$this->depth] = $this->indentationForDepths[$this->depth] + $this->getPreferredIndentationSize(); } private function isEOF(int $position = null) { $position = null === $position ? $this->currentPosition : $position; return $position === \strlen($this->contents); } private function isPreviousLineComment(int $position): bool { $line = $this->getPreviousLine($position); if (null === $line) { return false; } return $this->isLineComment($line); } private function isCurrentLineComment(int $position): bool { $line = $this->getCurrentLine($position); if (null === $line) { return false; } return $this->isLineComment($line); } private function isLineComment(string $line): bool { // adopted from Parser::isCurrentLineComment() from symfony/yaml $ltrimmedLine = ltrim($line, ' '); return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0]; } private function isFinalLineComment(string $content): bool { if (!$content) { return false; } $content = str_replace("\r", "\n", $content); $lines = explode("\n", $content); $line = end($lines); return $this->isLineComment($line); } private function getPreviousLine(int $position) { // find the previous \n by finding the last one in the content up to the position $endPos = strrpos(substr($this->contents, 0, $position), "\n"); if (false === $endPos) { // there is no previous line return null; } $startPos = strrpos(substr($this->contents, 0, $endPos), "\n"); if (false === $startPos) { // we're at the beginning of the file $startPos = 0; } else { // move 1 past the line break ++$startPos; } $previousLine = substr($this->contents, $startPos, $endPos - $startPos); return trim($previousLine, "\r"); } private function getCurrentLine(int $position) { $startPos = strrpos(substr($this->contents, 0, $position), "\n") + 1; $endPos = strpos($this->contents, "\n", $startPos); $this->log(sprintf('Looking for current line from %d to %d', $startPos, $endPos)); $line = substr($this->contents, $startPos, $endPos - $startPos); return trim($line, "\r"); } private function findNextLineBreak(int $position) { $nextNPos = strpos($this->contents, "\n", $position); $nextRPos = strpos($this->contents, "\r", $position); if (false === $nextNPos) { return false; } if (false === $nextRPos) { return $nextNPos; } // find the first possible line break character $nextLinePos = min($nextNPos, $nextRPos); // check for a \r\n situation if (($nextLinePos + 1) === $nextNPos) { ++$nextLinePos; } return $nextLinePos; } private function isCharLineBreak(string $char): bool { return "\n" === $char || "\r" === $char; } /** * Takes an unindented multi-line YAML string and indents it so * it can be inserted into the current position. * * Usually an empty line needs to be prepended to this result before * adding to the content. */ private function indentMultilineYamlArray(string $yaml, int $indentOverride = null): string { $indent = $this->getCurrentIndentation($indentOverride); // But, if the *value* is an array, then ITS children will // also need to be indented artificially by the same amount $yaml = str_replace("\n", "\n".$indent, $yaml); if ($this->isMultilineString($yaml)) { // Remove extra indentation in case of blank line in multiline string $yaml = str_replace("\n".$indent."\n", "\n\n", $yaml); } // now indent this level return $indent.$yaml; } private function findPositionOfMultilineCharInLine(int $position): ?int { $cursor = $position; while (!$this->isCharLineBreak($currentChar = substr($this->contents, $cursor + 1, 1)) && !$this->isEOF($cursor)) { if ('|' === $currentChar) { return $cursor; } ++$cursor; } return null; } private function isMultilineString($value): bool { return \is_string($value) && false !== strpos($value, "\n"); } }