Parser.php 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Yaml;
  11. use Symfony\Component\Yaml\Exception\ParseException;
  12. use Symfony\Component\Yaml\Tag\TaggedValue;
  13. /**
  14. * Parser parses YAML strings to convert them to PHP arrays.
  15. *
  16. * @author Fabien Potencier <fabien@symfony.com>
  17. *
  18. * @final
  19. */
  20. class Parser
  21. {
  22. const TAG_PATTERN = '(?P<tag>![\w!.\/:-]+)';
  23. const BLOCK_SCALAR_HEADER_PATTERN = '(?P<separator>\||>)(?P<modifiers>\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P<comments> +#.*)?';
  24. private $filename;
  25. private $offset = 0;
  26. private $totalNumberOfLines;
  27. private $lines = array();
  28. private $currentLineNb = -1;
  29. private $currentLine = '';
  30. private $refs = array();
  31. private $skippedLineNumbers = array();
  32. private $locallySkippedLineNumbers = array();
  33. /**
  34. * Parses a YAML file into a PHP value.
  35. *
  36. * @param string $filename The path to the YAML file to be parsed
  37. * @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
  38. *
  39. * @return mixed The YAML converted to a PHP value
  40. *
  41. * @throws ParseException If the file could not be read or the YAML is not valid
  42. */
  43. public function parseFile(string $filename, int $flags = 0)
  44. {
  45. if (!is_file($filename)) {
  46. throw new ParseException(sprintf('File "%s" does not exist.', $filename));
  47. }
  48. if (!is_readable($filename)) {
  49. throw new ParseException(sprintf('File "%s" cannot be read.', $filename));
  50. }
  51. $this->filename = $filename;
  52. try {
  53. return $this->parse(file_get_contents($filename), $flags);
  54. } finally {
  55. $this->filename = null;
  56. }
  57. }
  58. /**
  59. * Parses a YAML string to a PHP value.
  60. *
  61. * @param string $value A YAML string
  62. * @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
  63. *
  64. * @return mixed A PHP value
  65. *
  66. * @throws ParseException If the YAML is not valid
  67. */
  68. public function parse(string $value, int $flags = 0)
  69. {
  70. if (false === preg_match('//u', $value)) {
  71. throw new ParseException('The YAML value does not appear to be valid UTF-8.', -1, null, $this->filename);
  72. }
  73. $this->refs = array();
  74. $mbEncoding = null;
  75. $data = null;
  76. if (2 /* MB_OVERLOAD_STRING */ & (int) ini_get('mbstring.func_overload')) {
  77. $mbEncoding = mb_internal_encoding();
  78. mb_internal_encoding('UTF-8');
  79. }
  80. try {
  81. $data = $this->doParse($value, $flags);
  82. } finally {
  83. if (null !== $mbEncoding) {
  84. mb_internal_encoding($mbEncoding);
  85. }
  86. $this->lines = array();
  87. $this->currentLine = '';
  88. $this->refs = array();
  89. $this->skippedLineNumbers = array();
  90. $this->locallySkippedLineNumbers = array();
  91. }
  92. return $data;
  93. }
  94. /**
  95. * @internal
  96. *
  97. * @return int
  98. */
  99. public function getLastLineNumberBeforeDeprecation(): int
  100. {
  101. return $this->getRealCurrentLineNb();
  102. }
  103. private function doParse(string $value, int $flags)
  104. {
  105. $this->currentLineNb = -1;
  106. $this->currentLine = '';
  107. $value = $this->cleanup($value);
  108. $this->lines = explode("\n", $value);
  109. $this->locallySkippedLineNumbers = array();
  110. if (null === $this->totalNumberOfLines) {
  111. $this->totalNumberOfLines = \count($this->lines);
  112. }
  113. if (!$this->moveToNextLine()) {
  114. return null;
  115. }
  116. $data = array();
  117. $context = null;
  118. $allowOverwrite = false;
  119. while ($this->isCurrentLineEmpty()) {
  120. if (!$this->moveToNextLine()) {
  121. return null;
  122. }
  123. }
  124. // Resolves the tag and returns if end of the document
  125. if (null !== ($tag = $this->getLineTag($this->currentLine, $flags, false)) && !$this->moveToNextLine()) {
  126. return new TaggedValue($tag, '');
  127. }
  128. do {
  129. if ($this->isCurrentLineEmpty()) {
  130. continue;
  131. }
  132. // tab?
  133. if ("\t" === $this->currentLine[0]) {
  134. throw new ParseException('A YAML file cannot contain tabs as indentation.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  135. }
  136. Inline::initialize($flags, $this->getRealCurrentLineNb(), $this->filename);
  137. $isRef = $mergeNode = false;
  138. if ('-' === $this->currentLine[0] && self::preg_match('#^\-((?P<leadspaces>\s+)(?P<value>.+))?$#u', rtrim($this->currentLine), $values)) {
  139. if ($context && 'mapping' == $context) {
  140. throw new ParseException('You cannot define a sequence item when in a mapping', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  141. }
  142. $context = 'sequence';
  143. if (isset($values['value']) && '&' === $values['value'][0] && self::preg_match('#^&(?P<ref>[^ ]+) *(?P<value>.*)#u', $values['value'], $matches)) {
  144. $isRef = $matches['ref'];
  145. $values['value'] = $matches['value'];
  146. }
  147. if (isset($values['value'][1]) && '?' === $values['value'][0] && ' ' === $values['value'][1]) {
  148. throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
  149. }
  150. // array
  151. if (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) {
  152. $data[] = $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(null, true) ?? '', $flags);
  153. } elseif (null !== $subTag = $this->getLineTag(ltrim($values['value'], ' '), $flags)) {
  154. $data[] = new TaggedValue(
  155. $subTag,
  156. $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(null, true), $flags)
  157. );
  158. } else {
  159. if (isset($values['leadspaces'])
  160. && self::preg_match('#^(?P<key>'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P<value>.+?))?\s*$#u', $this->trimTag($values['value']), $matches)
  161. ) {
  162. // this is a compact notation element, add to next block and parse
  163. $block = $values['value'];
  164. if ($this->isNextLineIndented()) {
  165. $block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + \strlen($values['leadspaces']) + 1);
  166. }
  167. $data[] = $this->parseBlock($this->getRealCurrentLineNb(), $block, $flags);
  168. } else {
  169. $data[] = $this->parseValue($values['value'], $flags, $context);
  170. }
  171. }
  172. if ($isRef) {
  173. $this->refs[$isRef] = end($data);
  174. }
  175. } elseif (
  176. self::preg_match('#^(?P<key>(?:![^\s]++\s++)?(?:'.Inline::REGEX_QUOTED_STRING.'|(?:!?!php/const:)?[^ \'"\[\{!].*?)) *\:(\s++(?P<value>.+))?$#u', rtrim($this->currentLine), $values)
  177. && (false === strpos($values['key'], ' #') || \in_array($values['key'][0], array('"', "'")))
  178. ) {
  179. if ($context && 'sequence' == $context) {
  180. throw new ParseException('You cannot define a mapping item when in a sequence', $this->currentLineNb + 1, $this->currentLine, $this->filename);
  181. }
  182. $context = 'mapping';
  183. try {
  184. $key = Inline::parseScalar($values['key']);
  185. } catch (ParseException $e) {
  186. $e->setParsedLine($this->getRealCurrentLineNb() + 1);
  187. $e->setSnippet($this->currentLine);
  188. throw $e;
  189. }
  190. if (!\is_string($key) && !\is_int($key)) {
  191. throw new ParseException(sprintf('%s keys are not supported. Quote your evaluable mapping keys instead.', is_numeric($key) ? 'Numeric' : 'Non-string'), $this->getRealCurrentLineNb() + 1, $this->currentLine);
  192. }
  193. // Convert float keys to strings, to avoid being converted to integers by PHP
  194. if (\is_float($key)) {
  195. $key = (string) $key;
  196. }
  197. if ('<<' === $key && (!isset($values['value']) || '&' !== $values['value'][0] || !self::preg_match('#^&(?P<ref>[^ ]+)#u', $values['value'], $refMatches))) {
  198. $mergeNode = true;
  199. $allowOverwrite = true;
  200. if (isset($values['value'][0]) && '*' === $values['value'][0]) {
  201. $refName = substr(rtrim($values['value']), 1);
  202. if (!array_key_exists($refName, $this->refs)) {
  203. throw new ParseException(sprintf('Reference "%s" does not exist.', $refName), $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  204. }
  205. $refValue = $this->refs[$refName];
  206. if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $refValue instanceof \stdClass) {
  207. $refValue = (array) $refValue;
  208. }
  209. if (!\is_array($refValue)) {
  210. throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  211. }
  212. $data += $refValue; // array union
  213. } else {
  214. if (isset($values['value']) && '' !== $values['value']) {
  215. $value = $values['value'];
  216. } else {
  217. $value = $this->getNextEmbedBlock();
  218. }
  219. $parsed = $this->parseBlock($this->getRealCurrentLineNb() + 1, $value, $flags);
  220. if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsed instanceof \stdClass) {
  221. $parsed = (array) $parsed;
  222. }
  223. if (!\is_array($parsed)) {
  224. throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  225. }
  226. if (isset($parsed[0])) {
  227. // If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes
  228. // and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier
  229. // in the sequence override keys specified in later mapping nodes.
  230. foreach ($parsed as $parsedItem) {
  231. if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsedItem instanceof \stdClass) {
  232. $parsedItem = (array) $parsedItem;
  233. }
  234. if (!\is_array($parsedItem)) {
  235. throw new ParseException('Merge items must be arrays.', $this->getRealCurrentLineNb() + 1, $parsedItem, $this->filename);
  236. }
  237. $data += $parsedItem; // array union
  238. }
  239. } else {
  240. // If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the
  241. // current mapping, unless the key already exists in it.
  242. $data += $parsed; // array union
  243. }
  244. }
  245. } elseif ('<<' !== $key && isset($values['value']) && '&' === $values['value'][0] && self::preg_match('#^&(?P<ref>[^ ]++) *+(?P<value>.*)#u', $values['value'], $matches)) {
  246. $isRef = $matches['ref'];
  247. $values['value'] = $matches['value'];
  248. }
  249. $subTag = null;
  250. if ($mergeNode) {
  251. // Merge keys
  252. } elseif (!isset($values['value']) || '' === $values['value'] || 0 === strpos($values['value'], '#') || (null !== $subTag = $this->getLineTag($values['value'], $flags)) || '<<' === $key) {
  253. // hash
  254. // if next line is less indented or equal, then it means that the current value is null
  255. if (!$this->isNextLineIndented() && !$this->isNextLineUnIndentedCollection()) {
  256. // Spec: Keys MUST be unique; first one wins.
  257. // But overwriting is allowed when a merge node is used in current block.
  258. if ($allowOverwrite || !isset($data[$key])) {
  259. if (null !== $subTag) {
  260. $data[$key] = new TaggedValue($subTag, '');
  261. } else {
  262. $data[$key] = null;
  263. }
  264. } else {
  265. throw new ParseException(sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine);
  266. }
  267. } else {
  268. // remember the parsed line number here in case we need it to provide some contexts in error messages below
  269. $realCurrentLineNbKey = $this->getRealCurrentLineNb();
  270. $value = $this->parseBlock($this->getRealCurrentLineNb() + 1, $this->getNextEmbedBlock(), $flags);
  271. if ('<<' === $key) {
  272. $this->refs[$refMatches['ref']] = $value;
  273. if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $value instanceof \stdClass) {
  274. $value = (array) $value;
  275. }
  276. $data += $value;
  277. } elseif ($allowOverwrite || !isset($data[$key])) {
  278. // Spec: Keys MUST be unique; first one wins.
  279. // But overwriting is allowed when a merge node is used in current block.
  280. if (null !== $subTag) {
  281. $data[$key] = new TaggedValue($subTag, $value);
  282. } else {
  283. $data[$key] = $value;
  284. }
  285. } else {
  286. throw new ParseException(sprintf('Duplicate key "%s" detected.', $key), $realCurrentLineNbKey + 1, $this->currentLine);
  287. }
  288. }
  289. } else {
  290. $value = $this->parseValue(rtrim($values['value']), $flags, $context);
  291. // Spec: Keys MUST be unique; first one wins.
  292. // But overwriting is allowed when a merge node is used in current block.
  293. if ($allowOverwrite || !isset($data[$key])) {
  294. $data[$key] = $value;
  295. } else {
  296. throw new ParseException(sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine);
  297. }
  298. }
  299. if ($isRef) {
  300. $this->refs[$isRef] = $data[$key];
  301. }
  302. } else {
  303. // multiple documents are not supported
  304. if ('---' === $this->currentLine) {
  305. throw new ParseException('Multiple documents are not supported.', $this->currentLineNb + 1, $this->currentLine, $this->filename);
  306. }
  307. if ($deprecatedUsage = (isset($this->currentLine[1]) && '?' === $this->currentLine[0] && ' ' === $this->currentLine[1])) {
  308. throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine);
  309. }
  310. // 1-liner optionally followed by newline(s)
  311. if (\is_string($value) && $this->lines[0] === trim($value)) {
  312. try {
  313. $value = Inline::parse($this->lines[0], $flags, $this->refs);
  314. } catch (ParseException $e) {
  315. $e->setParsedLine($this->getRealCurrentLineNb() + 1);
  316. $e->setSnippet($this->currentLine);
  317. throw $e;
  318. }
  319. return $value;
  320. }
  321. // try to parse the value as a multi-line string as a last resort
  322. if (0 === $this->currentLineNb) {
  323. $previousLineWasNewline = false;
  324. $previousLineWasTerminatedWithBackslash = false;
  325. $value = '';
  326. foreach ($this->lines as $line) {
  327. // If the indentation is not consistent at offset 0, it is to be considered as a ParseError
  328. if (0 === $this->offset && !$deprecatedUsage && isset($line[0]) && ' ' === $line[0]) {
  329. throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  330. }
  331. if ('' === trim($line)) {
  332. $value .= "\n";
  333. } elseif (!$previousLineWasNewline && !$previousLineWasTerminatedWithBackslash) {
  334. $value .= ' ';
  335. }
  336. if ('' !== trim($line) && '\\' === substr($line, -1)) {
  337. $value .= ltrim(substr($line, 0, -1));
  338. } elseif ('' !== trim($line)) {
  339. $value .= trim($line);
  340. }
  341. if ('' === trim($line)) {
  342. $previousLineWasNewline = true;
  343. $previousLineWasTerminatedWithBackslash = false;
  344. } elseif ('\\' === substr($line, -1)) {
  345. $previousLineWasNewline = false;
  346. $previousLineWasTerminatedWithBackslash = true;
  347. } else {
  348. $previousLineWasNewline = false;
  349. $previousLineWasTerminatedWithBackslash = false;
  350. }
  351. }
  352. try {
  353. return Inline::parse(trim($value));
  354. } catch (ParseException $e) {
  355. // fall-through to the ParseException thrown below
  356. }
  357. }
  358. throw new ParseException('Unable to parse.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  359. }
  360. } while ($this->moveToNextLine());
  361. if (null !== $tag) {
  362. $data = new TaggedValue($tag, $data);
  363. }
  364. if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && !\is_object($data) && 'mapping' === $context) {
  365. $object = new \stdClass();
  366. foreach ($data as $key => $value) {
  367. $object->$key = $value;
  368. }
  369. $data = $object;
  370. }
  371. return empty($data) ? null : $data;
  372. }
  373. private function parseBlock(int $offset, string $yaml, int $flags)
  374. {
  375. $skippedLineNumbers = $this->skippedLineNumbers;
  376. foreach ($this->locallySkippedLineNumbers as $lineNumber) {
  377. if ($lineNumber < $offset) {
  378. continue;
  379. }
  380. $skippedLineNumbers[] = $lineNumber;
  381. }
  382. $parser = new self();
  383. $parser->offset = $offset;
  384. $parser->totalNumberOfLines = $this->totalNumberOfLines;
  385. $parser->skippedLineNumbers = $skippedLineNumbers;
  386. $parser->refs = &$this->refs;
  387. return $parser->doParse($yaml, $flags);
  388. }
  389. /**
  390. * Returns the current line number (takes the offset into account).
  391. *
  392. * @internal
  393. *
  394. * @return int The current line number
  395. */
  396. public function getRealCurrentLineNb(): int
  397. {
  398. $realCurrentLineNumber = $this->currentLineNb + $this->offset;
  399. foreach ($this->skippedLineNumbers as $skippedLineNumber) {
  400. if ($skippedLineNumber > $realCurrentLineNumber) {
  401. break;
  402. }
  403. ++$realCurrentLineNumber;
  404. }
  405. return $realCurrentLineNumber;
  406. }
  407. /**
  408. * Returns the current line indentation.
  409. *
  410. * @return int The current line indentation
  411. */
  412. private function getCurrentLineIndentation(): int
  413. {
  414. return \strlen($this->currentLine) - \strlen(ltrim($this->currentLine, ' '));
  415. }
  416. /**
  417. * Returns the next embed block of YAML.
  418. *
  419. * @param int|null $indentation The indent level at which the block is to be read, or null for default
  420. * @param bool $inSequence True if the enclosing data structure is a sequence
  421. *
  422. * @return string A YAML string
  423. *
  424. * @throws ParseException When indentation problem are detected
  425. */
  426. private function getNextEmbedBlock(int $indentation = null, bool $inSequence = false): ?string
  427. {
  428. $oldLineIndentation = $this->getCurrentLineIndentation();
  429. if (!$this->moveToNextLine()) {
  430. return null;
  431. }
  432. if (null === $indentation) {
  433. $newIndent = null;
  434. $movements = 0;
  435. do {
  436. $EOF = false;
  437. // empty and comment-like lines do not influence the indentation depth
  438. if ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()) {
  439. $EOF = !$this->moveToNextLine();
  440. if (!$EOF) {
  441. ++$movements;
  442. }
  443. } else {
  444. $newIndent = $this->getCurrentLineIndentation();
  445. }
  446. } while (!$EOF && null === $newIndent);
  447. for ($i = 0; $i < $movements; ++$i) {
  448. $this->moveToPreviousLine();
  449. }
  450. $unindentedEmbedBlock = $this->isStringUnIndentedCollectionItem();
  451. if (!$this->isCurrentLineEmpty() && 0 === $newIndent && !$unindentedEmbedBlock) {
  452. throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  453. }
  454. } else {
  455. $newIndent = $indentation;
  456. }
  457. $data = array();
  458. if ($this->getCurrentLineIndentation() >= $newIndent) {
  459. $data[] = substr($this->currentLine, $newIndent);
  460. } elseif ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()) {
  461. $data[] = $this->currentLine;
  462. } else {
  463. $this->moveToPreviousLine();
  464. return null;
  465. }
  466. if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) {
  467. // the previous line contained a dash but no item content, this line is a sequence item with the same indentation
  468. // and therefore no nested list or mapping
  469. $this->moveToPreviousLine();
  470. return null;
  471. }
  472. $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem();
  473. while ($this->moveToNextLine()) {
  474. $indent = $this->getCurrentLineIndentation();
  475. if ($isItUnindentedCollection && !$this->isCurrentLineEmpty() && !$this->isStringUnIndentedCollectionItem() && $newIndent === $indent) {
  476. $this->moveToPreviousLine();
  477. break;
  478. }
  479. if ($this->isCurrentLineBlank()) {
  480. $data[] = substr($this->currentLine, $newIndent);
  481. continue;
  482. }
  483. if ($indent >= $newIndent) {
  484. $data[] = substr($this->currentLine, $newIndent);
  485. } elseif ($this->isCurrentLineComment()) {
  486. $data[] = $this->currentLine;
  487. } elseif (0 == $indent) {
  488. $this->moveToPreviousLine();
  489. break;
  490. } else {
  491. throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
  492. }
  493. }
  494. return implode("\n", $data);
  495. }
  496. /**
  497. * Moves the parser to the next line.
  498. *
  499. * @return bool
  500. */
  501. private function moveToNextLine(): bool
  502. {
  503. if ($this->currentLineNb >= \count($this->lines) - 1) {
  504. return false;
  505. }
  506. $this->currentLine = $this->lines[++$this->currentLineNb];
  507. return true;
  508. }
  509. /**
  510. * Moves the parser to the previous line.
  511. *
  512. * @return bool
  513. */
  514. private function moveToPreviousLine(): bool
  515. {
  516. if ($this->currentLineNb < 1) {
  517. return false;
  518. }
  519. $this->currentLine = $this->lines[--$this->currentLineNb];
  520. return true;
  521. }
  522. /**
  523. * Parses a YAML value.
  524. *
  525. * @param string $value A YAML value
  526. * @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
  527. * @param string $context The parser context (either sequence or mapping)
  528. *
  529. * @return mixed A PHP value
  530. *
  531. * @throws ParseException When reference does not exist
  532. */
  533. private function parseValue(string $value, int $flags, string $context)
  534. {
  535. if (0 === strpos($value, '*')) {
  536. if (false !== $pos = strpos($value, '#')) {
  537. $value = substr($value, 1, $pos - 2);
  538. } else {
  539. $value = substr($value, 1);
  540. }
  541. if (!array_key_exists($value, $this->refs)) {
  542. throw new ParseException(sprintf('Reference "%s" does not exist.', $value), $this->currentLineNb + 1, $this->currentLine, $this->filename);
  543. }
  544. return $this->refs[$value];
  545. }
  546. if (\in_array($value[0], array('!', '|', '>'), true) && self::preg_match('/^(?:'.self::TAG_PATTERN.' +)?'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value, $matches)) {
  547. $modifiers = isset($matches['modifiers']) ? $matches['modifiers'] : '';
  548. $data = $this->parseBlockScalar($matches['separator'], preg_replace('#\d+#', '', $modifiers), (int) abs($modifiers));
  549. if ('' !== $matches['tag'] && '!' !== $matches['tag']) {
  550. if ('!!binary' === $matches['tag']) {
  551. return Inline::evaluateBinaryScalar($data);
  552. }
  553. return new TaggedValue(substr($matches['tag'], 1), $data);
  554. }
  555. return $data;
  556. }
  557. try {
  558. $quotation = '' !== $value && ('"' === $value[0] || "'" === $value[0]) ? $value[0] : null;
  559. // do not take following lines into account when the current line is a quoted single line value
  560. if (null !== $quotation && self::preg_match('/^'.$quotation.'.*'.$quotation.'(\s*#.*)?$/', $value)) {
  561. return Inline::parse($value, $flags, $this->refs);
  562. }
  563. $lines = array();
  564. while ($this->moveToNextLine()) {
  565. // unquoted strings end before the first unindented line
  566. if (null === $quotation && 0 === $this->getCurrentLineIndentation()) {
  567. $this->moveToPreviousLine();
  568. break;
  569. }
  570. $lines[] = trim($this->currentLine);
  571. // quoted string values end with a line that is terminated with the quotation character
  572. if ('' !== $this->currentLine && substr($this->currentLine, -1) === $quotation) {
  573. break;
  574. }
  575. }
  576. for ($i = 0, $linesCount = \count($lines), $previousLineBlank = false; $i < $linesCount; ++$i) {
  577. if ('' === $lines[$i]) {
  578. $value .= "\n";
  579. $previousLineBlank = true;
  580. } elseif ($previousLineBlank) {
  581. $value .= $lines[$i];
  582. $previousLineBlank = false;
  583. } else {
  584. $value .= ' '.$lines[$i];
  585. $previousLineBlank = false;
  586. }
  587. }
  588. Inline::$parsedLineNumber = $this->getRealCurrentLineNb();
  589. $parsedValue = Inline::parse($value, $flags, $this->refs);
  590. if ('mapping' === $context && \is_string($parsedValue) && '"' !== $value[0] && "'" !== $value[0] && '[' !== $value[0] && '{' !== $value[0] && '!' !== $value[0] && false !== strpos($parsedValue, ': ')) {
  591. throw new ParseException('A colon cannot be used in an unquoted mapping value.', $this->getRealCurrentLineNb() + 1, $value, $this->filename);
  592. }
  593. return $parsedValue;
  594. } catch (ParseException $e) {
  595. $e->setParsedLine($this->getRealCurrentLineNb() + 1);
  596. $e->setSnippet($this->currentLine);
  597. throw $e;
  598. }
  599. }
  600. /**
  601. * Parses a block scalar.
  602. *
  603. * @param string $style The style indicator that was used to begin this block scalar (| or >)
  604. * @param string $chomping The chomping indicator that was used to begin this block scalar (+ or -)
  605. * @param int $indentation The indentation indicator that was used to begin this block scalar
  606. *
  607. * @return string The text value
  608. */
  609. private function parseBlockScalar(string $style, string $chomping = '', int $indentation = 0): string
  610. {
  611. $notEOF = $this->moveToNextLine();
  612. if (!$notEOF) {
  613. return '';
  614. }
  615. $isCurrentLineBlank = $this->isCurrentLineBlank();
  616. $blockLines = array();
  617. // leading blank lines are consumed before determining indentation
  618. while ($notEOF && $isCurrentLineBlank) {
  619. // newline only if not EOF
  620. if ($notEOF = $this->moveToNextLine()) {
  621. $blockLines[] = '';
  622. $isCurrentLineBlank = $this->isCurrentLineBlank();
  623. }
  624. }
  625. // determine indentation if not specified
  626. if (0 === $indentation) {
  627. $currentLineLength = \strlen($this->currentLine);
  628. for ($i = 0; $i < $currentLineLength && ' ' === $this->currentLine[$i]; ++$i) {
  629. ++$indentation;
  630. }
  631. }
  632. if ($indentation > 0) {
  633. $pattern = sprintf('/^ {%d}(.*)$/', $indentation);
  634. while (
  635. $notEOF && (
  636. $isCurrentLineBlank ||
  637. self::preg_match($pattern, $this->currentLine, $matches)
  638. )
  639. ) {
  640. if ($isCurrentLineBlank && \strlen($this->currentLine) > $indentation) {
  641. $blockLines[] = substr($this->currentLine, $indentation);
  642. } elseif ($isCurrentLineBlank) {
  643. $blockLines[] = '';
  644. } else {
  645. $blockLines[] = $matches[1];
  646. }
  647. // newline only if not EOF
  648. if ($notEOF = $this->moveToNextLine()) {
  649. $isCurrentLineBlank = $this->isCurrentLineBlank();
  650. }
  651. }
  652. } elseif ($notEOF) {
  653. $blockLines[] = '';
  654. }
  655. if ($notEOF) {
  656. $blockLines[] = '';
  657. $this->moveToPreviousLine();
  658. } elseif (!$notEOF && !$this->isCurrentLineLastLineInDocument()) {
  659. $blockLines[] = '';
  660. }
  661. // folded style
  662. if ('>' === $style) {
  663. $text = '';
  664. $previousLineIndented = false;
  665. $previousLineBlank = false;
  666. for ($i = 0, $blockLinesCount = \count($blockLines); $i < $blockLinesCount; ++$i) {
  667. if ('' === $blockLines[$i]) {
  668. $text .= "\n";
  669. $previousLineIndented = false;
  670. $previousLineBlank = true;
  671. } elseif (' ' === $blockLines[$i][0]) {
  672. $text .= "\n".$blockLines[$i];
  673. $previousLineIndented = true;
  674. $previousLineBlank = false;
  675. } elseif ($previousLineIndented) {
  676. $text .= "\n".$blockLines[$i];
  677. $previousLineIndented = false;
  678. $previousLineBlank = false;
  679. } elseif ($previousLineBlank || 0 === $i) {
  680. $text .= $blockLines[$i];
  681. $previousLineIndented = false;
  682. $previousLineBlank = false;
  683. } else {
  684. $text .= ' '.$blockLines[$i];
  685. $previousLineIndented = false;
  686. $previousLineBlank = false;
  687. }
  688. }
  689. } else {
  690. $text = implode("\n", $blockLines);
  691. }
  692. // deal with trailing newlines
  693. if ('' === $chomping) {
  694. $text = preg_replace('/\n+$/', "\n", $text);
  695. } elseif ('-' === $chomping) {
  696. $text = preg_replace('/\n+$/', '', $text);
  697. }
  698. return $text;
  699. }
  700. /**
  701. * Returns true if the next line is indented.
  702. *
  703. * @return bool Returns true if the next line is indented, false otherwise
  704. */
  705. private function isNextLineIndented(): bool
  706. {
  707. $currentIndentation = $this->getCurrentLineIndentation();
  708. $movements = 0;
  709. do {
  710. $EOF = !$this->moveToNextLine();
  711. if (!$EOF) {
  712. ++$movements;
  713. }
  714. } while (!$EOF && ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()));
  715. if ($EOF) {
  716. return false;
  717. }
  718. $ret = $this->getCurrentLineIndentation() > $currentIndentation;
  719. for ($i = 0; $i < $movements; ++$i) {
  720. $this->moveToPreviousLine();
  721. }
  722. return $ret;
  723. }
  724. /**
  725. * Returns true if the current line is blank or if it is a comment line.
  726. *
  727. * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise
  728. */
  729. private function isCurrentLineEmpty(): bool
  730. {
  731. return $this->isCurrentLineBlank() || $this->isCurrentLineComment();
  732. }
  733. /**
  734. * Returns true if the current line is blank.
  735. *
  736. * @return bool Returns true if the current line is blank, false otherwise
  737. */
  738. private function isCurrentLineBlank(): bool
  739. {
  740. return '' == trim($this->currentLine, ' ');
  741. }
  742. /**
  743. * Returns true if the current line is a comment line.
  744. *
  745. * @return bool Returns true if the current line is a comment line, false otherwise
  746. */
  747. private function isCurrentLineComment(): bool
  748. {
  749. //checking explicitly the first char of the trim is faster than loops or strpos
  750. $ltrimmedLine = ltrim($this->currentLine, ' ');
  751. return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0];
  752. }
  753. private function isCurrentLineLastLineInDocument(): bool
  754. {
  755. return ($this->offset + $this->currentLineNb) >= ($this->totalNumberOfLines - 1);
  756. }
  757. /**
  758. * Cleanups a YAML string to be parsed.
  759. *
  760. * @param string $value The input YAML string
  761. *
  762. * @return string A cleaned up YAML string
  763. */
  764. private function cleanup(string $value): string
  765. {
  766. $value = str_replace(array("\r\n", "\r"), "\n", $value);
  767. // strip YAML header
  768. $count = 0;
  769. $value = preg_replace('#^\%YAML[: ][\d\.]+.*\n#u', '', $value, -1, $count);
  770. $this->offset += $count;
  771. // remove leading comments
  772. $trimmedValue = preg_replace('#^(\#.*?\n)+#s', '', $value, -1, $count);
  773. if (1 === $count) {
  774. // items have been removed, update the offset
  775. $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n");
  776. $value = $trimmedValue;
  777. }
  778. // remove start of the document marker (---)
  779. $trimmedValue = preg_replace('#^\-\-\-.*?\n#s', '', $value, -1, $count);
  780. if (1 === $count) {
  781. // items have been removed, update the offset
  782. $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n");
  783. $value = $trimmedValue;
  784. // remove end of the document marker (...)
  785. $value = preg_replace('#\.\.\.\s*$#', '', $value);
  786. }
  787. return $value;
  788. }
  789. /**
  790. * Returns true if the next line starts unindented collection.
  791. *
  792. * @return bool Returns true if the next line starts unindented collection, false otherwise
  793. */
  794. private function isNextLineUnIndentedCollection(): bool
  795. {
  796. $currentIndentation = $this->getCurrentLineIndentation();
  797. $movements = 0;
  798. do {
  799. $EOF = !$this->moveToNextLine();
  800. if (!$EOF) {
  801. ++$movements;
  802. }
  803. } while (!$EOF && ($this->isCurrentLineEmpty() || $this->isCurrentLineComment()));
  804. if ($EOF) {
  805. return false;
  806. }
  807. $ret = $this->getCurrentLineIndentation() === $currentIndentation && $this->isStringUnIndentedCollectionItem();
  808. for ($i = 0; $i < $movements; ++$i) {
  809. $this->moveToPreviousLine();
  810. }
  811. return $ret;
  812. }
  813. /**
  814. * Returns true if the string is un-indented collection item.
  815. *
  816. * @return bool Returns true if the string is un-indented collection item, false otherwise
  817. */
  818. private function isStringUnIndentedCollectionItem(): bool
  819. {
  820. return '-' === rtrim($this->currentLine) || 0 === strpos($this->currentLine, '- ');
  821. }
  822. /**
  823. * A local wrapper for "preg_match" which will throw a ParseException if there
  824. * is an internal error in the PCRE engine.
  825. *
  826. * This avoids us needing to check for "false" every time PCRE is used
  827. * in the YAML engine
  828. *
  829. * @throws ParseException on a PCRE internal error
  830. *
  831. * @see preg_last_error()
  832. *
  833. * @internal
  834. */
  835. public static function preg_match(string $pattern, string $subject, array &$matches = null, int $flags = 0, int $offset = 0): int
  836. {
  837. if (false === $ret = preg_match($pattern, $subject, $matches, $flags, $offset)) {
  838. switch (preg_last_error()) {
  839. case PREG_INTERNAL_ERROR:
  840. $error = 'Internal PCRE error.';
  841. break;
  842. case PREG_BACKTRACK_LIMIT_ERROR:
  843. $error = 'pcre.backtrack_limit reached.';
  844. break;
  845. case PREG_RECURSION_LIMIT_ERROR:
  846. $error = 'pcre.recursion_limit reached.';
  847. break;
  848. case PREG_BAD_UTF8_ERROR:
  849. $error = 'Malformed UTF-8 data.';
  850. break;
  851. case PREG_BAD_UTF8_OFFSET_ERROR:
  852. $error = 'Offset doesn\'t correspond to the begin of a valid UTF-8 code point.';
  853. break;
  854. default:
  855. $error = 'Error.';
  856. }
  857. throw new ParseException($error);
  858. }
  859. return $ret;
  860. }
  861. /**
  862. * Trim the tag on top of the value.
  863. *
  864. * Prevent values such as "!foo {quz: bar}" to be considered as
  865. * a mapping block.
  866. */
  867. private function trimTag(string $value): string
  868. {
  869. if ('!' === $value[0]) {
  870. return ltrim(substr($value, 1, strcspn($value, " \r\n", 1)), ' ');
  871. }
  872. return $value;
  873. }
  874. private function getLineTag(string $value, int $flags, bool $nextLineCheck = true): ?string
  875. {
  876. if ('' === $value || '!' !== $value[0] || 1 !== self::preg_match('/^'.self::TAG_PATTERN.' *( +#.*)?$/', $value, $matches)) {
  877. return null;
  878. }
  879. if ($nextLineCheck && !$this->isNextLineIndented()) {
  880. return null;
  881. }
  882. $tag = substr($matches['tag'], 1);
  883. // Built-in tags
  884. if ($tag && '!' === $tag[0]) {
  885. throw new ParseException(sprintf('The built-in tag "!%s" is not implemented.', $tag), $this->getRealCurrentLineNb() + 1, $value, $this->filename);
  886. }
  887. if (Yaml::PARSE_CUSTOM_TAGS & $flags) {
  888. return $tag;
  889. }
  890. throw new ParseException(sprintf('Tags support is not enabled. You must use the flag "Yaml::PARSE_CUSTOM_TAGS" to use "%s".', $matches['tag']), $this->getRealCurrentLineNb() + 1, $value, $this->filename);
  891. }
  892. }