diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..fa53839 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,3 @@ +service_name: travis-ci +coverage_clover: clover.xml # file generated by phpunit +json_path: coverage.json # file generated by php-coveralls \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d4d4937 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/tests export-ignore +/phpunit.xml.dist export-ignore +/phpcs.xml.dist export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39c3ae8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a28a0f4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php +php: + - 7.2 + - 7.3 + - 7.4snapshot +dist: xenial +before_script: + - composer install --prefer-source --no-interaction +script: + - vendor/bin/phpunit --coverage-clover=clover.xml +after_script: + - wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.1.0/php-coveralls.phar + - php php-coveralls.phar --verbose +cache: + directories: + - $HOME/.composer/cache/files +after_success: + - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..70d3d38 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2019-10-12 + +This component has been decoupled from the [OriginPHP framework](https://www.originphp.com/). \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..8303eb3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2019] [Jamiel Sharief] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca5d9d5 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# Yaml + +![license](https://img.shields.io/badge/license-MIT-brightGreen.svg) +[![build](https://travis-ci.org/originphp/yaml.svg?branch=master)](https://travis-ci.org/originphp/yaml) +[![coverage](https://coveralls.io/repos/github/originphp/yaml/badge.svg?branch=master)](https://coveralls.io/github/originphp/yaml?branch=master) + + +The YAML utility is for reading (parsing) and writing YAML files. + +> The YAML utility does not cover the complete specification, it is designed to read and write configuration files, and data from the database so that it can be read and edited in user friendly way. + +## Installation + +To install this package + +```linux +$ composer require originphp/yaml +``` + +## Create a YAML string + +```php +use Origin\Yaml\Yaml; +$employees = [ + ['name'=>'Jim','skills'=>['php','mysql','puppeteer']], + ['name'=>'Amy','skills'=>['ruby','ruby on rails']], +]; +$yaml = Yaml::fromArray($employees ); +``` + +That will return the following + +```yaml +- name: Jim + skills: + - php + - mysql + - puppeteer +- name: Amy + skills: + - ruby + - ruby on rails +``` + +Here is an example converting a record using the Bookmark model + +```php + $bookmark = $this->Bookmark->get(1,[ + 'associated'=>[ + 'User','Tag' + ] + ]); +$string = Yaml::fromArray($bookmark->toArray()); + +``` + +This will create the following YAML string: + +```yaml +id: 1 +user_id: 1 +title: OriginPHP +description: The PHP framework for rapidly building scalable web applications. +url: https://www.originphp.com +category: Computing +created: 2018-12-28 15:25:34 +modified: 2019-05-02 13:08:44 +user: + id: 1 + name: Demo User + email: demo@example.com + password: $2y$10$/clqxdb.aWe43VXDUn8tA.yxKbWHZT3rN7gqITFaj32PZHI3.DkzW + dob: 1999-12-28 + created: 2018-12-28 15:24:13 + modified: 2018-12-28 15:24:13 +tags: + - id: 1 + title: Framework + created: 2019-03-07 18:45:43 + modified: 2019-03-07 18:45:43 + bookmarksTag: + bookmark_id: 1 + tag_id: 1 + - id: 2 + title: PHP + created: 2019-03-07 18:45:43 + modified: 2019-03-07 18:45:43 + bookmarksTag: + bookmark_id: 1 + tag_id: 2 +tag_string: Framework,PHP +``` + +## Read YAML + +To create an array from a YAML string + +```php +$employee = 'name: Tom +position: developer +skills: + - php + - mysql + - js +addresses: + - street: 14 some road + city: london + - street: 21 some avenue + city: leeds +summary: + this is a text description + for this employee'; +$array = Yaml::toArray($employee); +/* +Array +( + [name] => Tom + [position] => developer + [skills] => Array + ( + [0] => php + [1] => mysql + [2] => js + ) + + [addresses] => Array + ( + [0] => Array + ( + [street] => 14 some road + [city] => london + ) + + [1] => Array + ( + [street] => 21 some avenue + [city] => leeds + ) + + ) + + [summary] => this is a text description for this employee +) +*/ +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0571b63 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "originphp/yaml", + "description": "OriginPHP Yaml", + "type": "library", + "keywords": [ + "originphp", + "yaml", + "parser" + ], + "homepage": "https://www.originphp.com", + "license": "MIT", + "authors": [ + { + "name": "Jamiel Sharief", + "email": "js@originphp.com" + } + ], + "autoload": { + "psr-4": { + "Origin\\Yaml\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Origin\\Test\\Yaml\\": "tests/" + } + }, + "require": { + "php": "^7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.0" + } +} \ No newline at end of file diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..9f78e66 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,7 @@ + + + The coding standard for OriginPHP. + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..56fc175 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + ./tests/ + + + + + ./src/ + + + + + + + diff --git a/src/Exception/YamlException.php b/src/Exception/YamlException.php new file mode 100644 index 0000000..02da1f4 --- /dev/null +++ b/src/Exception/YamlException.php @@ -0,0 +1,22 @@ +) +* - multiple documents in a stream @see https://Yaml.org/spec/current.html#id2502724 +* - Surround in-line series branch. e.g [ ] +* - Surround in-line keyed branch. { } +* +* Known issues: parsing a docker compose file, the volumes for mysql-data is the value. +* volumes: +* mysql-data: +* @see https://Yaml.org/refcard.html + */ +namespace Origin\Yaml; + +use Origin\Yaml\Exception\YamlException; + +class YamlParser +{ + /** + * The line endings + */ + const EOF = "\r\n"; + + /** + * Copy of the source + * + * @var string + */ + protected $src = null; + + /** + * Array of the lines + * + * @var array + */ + protected $lines = []; + + /** + * Holds the line counter + * + * @var int + */ + protected $i = 0; + + /** + * Constructor + * + * @param string $src Yaml source + */ + public function __construct(string $src = null) + { + if ($src) { + $this->src = $src; + $this->lines = $this->readLines($src); + } + } + + /** + * This is used to manually create lines e.g list of lists. + * + * @param array $lines + * @return array + */ + public function lines(array $lines = null) : array + { + if ($lines) { + $this->lines = $lines; + } + + return $this->lines; + } + + /** + * Help identify record sets + * + * @param int $from + * @return array + */ + protected function findRecordSets(int $from) : array + { + $lines = count($this->lines); + $results = []; + $spaces = strpos($this->lines[$from], ltrim($this->lines[$from])); + + $start = null; + + for ($w = $from;$w < $lines;$w++) { + $marker = ltrim($this->lines[$w]); + if ($marker[0] === '-') { + if ($start !== null) { + $results[$start] = $w - 1; + } + $start = $w; + if ($marker !== '-') { + $marker = substr($marker, 1); + } + } + if (strpos($this->lines[$w], $marker) < $spaces) { + $results[$start] = $w - 1; + $start = null; + break; // its parent + } elseif ($w === ($lines - 1) and $start) { + $results[$start] = $w; // Reached end of of file + } + } + + return $results; + } + + /** + * Parses the array + * + * @param integer $lineNo from + * @return array + */ + protected function parse(int $lineNo = 0) : array + { + $result = []; + $lines = count($this->lines); + + $spaces = $lastSpaces = 0; + + for ($i = $lineNo;$i < $lines;$i++) { + $line = $this->lines[$i]; + $marker = trim($line); + + // Skip comments,empty lines and directive + if ($marker === '' or $marker[0] === '#' or $line === '---' or substr($line, 0, 5) === '%YAML') { + $this->i = $i; + continue; + } + + if ($line[0] === "\t") { + throw new YamlException('YAML documents should not use tabs for indentation'); + } + if ($line === '...') { + throw new YamlException('Multiple document streams are not supported.'); + } + + // Identify node level + $spaces = strpos($line, $marker); + if ($spaces > $lastSpaces) { + $lastSpaces = $spaces; + } elseif ($spaces < $lastSpaces) { + break; + } + + // Walk forward for multi line data + if (! $this->isList($line) and ! $this->isScalar($line) and ! $this->isParent($line)) { + $parentLine = $this->lines[$i - 1]; + if (! $this->isParent($parentLine)) { + continue; // Skip if there is no parent + } + $block = trim($line); + for ($w = $i + 1;$w < $lines;$w++) { + $nextLine = trim($this->lines[$w]); + if (! $this->isList($nextLine) and ! $this->isScalar($nextLine) and ! $this->isParent($nextLine)) { + $block .= ' ' . $nextLine; // In the plain scalar,newlines become spaces + } else { + break; + } + } + $this->i = $i = $w - 1; + + $result['__plain_scalar__'] = $block; + continue; + } + // Walk Forward to handle multiline data folded and literal + if (substr($line, -1) === '|' or substr($line, -1) === '>') { + list($key, $value) = explode(': ', ltrim($line)); + $value = ''; + /** + * > Folded style: line breaks replaced with space + * | literal style: line breaks count + * @see https://Yaml.org/spec/current.html#id2539942 + */ + $break = "\n"; + if (substr($line, -1) === '>') { + $break = ' '; + } + + for ($w = $i + 1;$w < $lines;$w++) { + $nextLine = trim($this->lines[$w]); + + // Handle multilines which are on the last lastline + if ($w === $lines - 1) { + $value .= $nextLine . $break; + } + + if ($this->isScalar($nextLine) or $this->isParent($nextLine) or $this->isList($nextLine) or $w === $lines - 1) { + $result[$key] = rtrim($value); + break; + } + $value .= $nextLine . $break; + } + $this->i = $i = $w - 1; + continue; + } + // Handle Lists + if ($this->isList($line)) { + $trimmedLine = ltrim(' '. substr(ltrim($line), 2)); // work with any number of spaces; + + if (trim($line) !== '-' and ! $this->isParent($trimmedLine) and ! $this->isScalar($trimmedLine)) { + $result[] = $trimmedLine; + } elseif ($this->isParent($trimmedLine)) { + $key = substr(ltrim($trimmedLine), 0, -1); + $result[$key] = $this->parse($i + 1); + $i = $this->i; + } else { + /** + * Deal with list sets. Going to seperate from the rest. remove + * the - from the start each set and pass through the parser (is this a hack?) + */ + $sets = $this->findRecordSets($i); + foreach ($sets as $start => $finish) { + $setLines = []; + for ($ii = $start;$ii < $finish + 1;$ii++) { + $setLine = $this->lines[$ii]; + + if ($ii === $start) { + if (trim($setLine) === '-') { + continue; + } else { + $setLine = str_replace('- ', ' ', $setLine); // Associate + } + } + $setLines[] = $setLine; + } + + $me = new YamlParser(); + $me->lines($setLines); + $result[] = $me->toArray(); + } + $i = $finish; + } + } elseif ($this->isScalar($line)) { + list($key, $value) = explode(': ', ltrim($line)); + $result[rtrim($key)] = $this->readValue($value); + } elseif ($this->isParent($line)) { + $line = ltrim($line); + $key = substr($line, 0, -1); + + $key = rtrim($key); // remove ending spaces e.g. invoice : + $result[$key] = $this->parse($i + 1); + // Walk backward + if (isset($result[$key]['__plain_scalar__'])) { + $result[$key] = $result[$key]['__plain_scalar__']; + } + + $i = $this->i; + } + $this->i = $i; + } + + return $result; + } + + /** + * Converts a string into an array of lines + * + * @param string $string + * @return array + */ + protected function readLines(string $string) : array + { + $lines = []; + $lines[] = $line = strtok($string, static::EOF); + while ($line !== false) { + $line = strtok(static::EOF); + if ($line) { + $lines[] = $line; + } + } + + return $lines; + } + + /** + * Checks if a line is parent + * + * @param string $line + * @return boolean + */ + protected function isParent(string $line): bool + { + return (substr(trim($line), -1) === ':'); + } + + /** + * Checks if a line is scalar value + * + * @param string $line + * @return boolean + */ + protected function isScalar(string $line) :bool + { + return (strpos($line, ': ') !== false); + } + + /** + * Checks if line is a list + * + * @param string $line + * @return bool + */ + protected function isList(string $line) : bool + { + $line = trim($line); + + return (substr($line, 0, 2) === '- ') or $line === '-'; + } + + /** + * Converts the string into an array + * + * @return array + */ + public function toArray() : array + { + return $this->parse(); + } + + /** + * Undocumented function + * Many types of bool + * @see https://Yaml.org/type/bool.html + * @param mixed $value + * @return mixed + */ + protected function readValue($value) + { + switch ($value) { + case 'true': + return true; + break; + case 'false': + return false; + break; + case 'null': + return null; + break; + } + + return trim($value, '"\''); // remove quotes spaces etc + } +} + +class Yaml +{ + const EOF = "\r\n"; + + protected static $indent = 2; + protected static $lines = []; + + /** + * Converts a YAML string into an Array + * + * @param string $string + * @return array + */ + public static function toArray(string $string) : array + { + $parser = new YamlParser($string); + + return $parser->toArray(); + } + + /** + * Converts an array into a YAML string + * + * @param array $array + * @return string + */ + public static function fromArray(array $array) : string + { + return self::dump($array); + } + + protected static function dump(array $array, int $indent = 0, $isList = false) + { + $output = ''; + $line = 0; + foreach ($array as $key => $value) { + if (is_array($value)) { + if (is_int($key)) { + $output .= self::dump($value, $indent, true); + } else { + $output .= str_repeat(' ', $indent) . "{$key}: \n"; + $output .= self::dump($value, $indent + self::$indent); + } + } else { + $value = self::dumpValue($value); + if (is_int($key)) { + $string = "- {$value}"; + } else { + $string = "{$key}: {$value}"; + } + if ($isList and $line == 0) { + $string = '- ' . $string; + } + $output .= str_repeat(' ', $indent) . "{$string}\n"; + if ($isList and $line == 0) { + $indent = $indent + 2; + } + } + $line ++; + } + + return $output; + } + + protected static function dumpValue($value) + { + if (is_bool($value)) { + return $value?'true':'false'; + } + if (is_null($value)) { + return null; + } + if (is_string($value) and strpos($value, "\n") !== false) { + $value = "| {$value}"; + } + + return $value; + } +} diff --git a/tests/YamlTest.php b/tests/YamlTest.php new file mode 100644 index 0000000..5204703 --- /dev/null +++ b/tests/YamlTest.php @@ -0,0 +1,313 @@ + 1234, + 'name' => 'james', + 'date' => '2019-05-05', + 'boolean' => false, + ]; + $Yaml = Yaml::fromArray($student); + $expected = <<< EOT +id: 1234 +name: james +date: 2019-05-05 +boolean: false +EOT; + $this->assertStringContainsString($expected, $Yaml); + } + public function testFromArrayYaml() + { + $student = [ + 'id' => 1234, + 'address' => [ + 'line' => '458 Some Road + Somewhere, Something', // multi line + 'city' => 'london', + ], + + ]; + $Yaml = Yaml::fromArray($student); + + $expected = <<< EOT +id: 1234 +address: + line: | 458 Some Road + Somewhere, Something + city: london +EOT; + $this->assertStringContainsString($expected, $Yaml); + } + + public function testFromList() + { + $students = ['tony','nick']; + $Yaml = Yaml::fromArray($students); + $expected = <<< EOT +- tony +- nick +EOT; + $this->assertStringContainsString($expected, $Yaml); + } + public function testFromChildList() + { + $students = [ + ['name' => 'tony','phones' => ['1234-456']], + ['name' => 'nick','phones' => ['1234-456','456-4334']], + ]; + $Yaml = Yaml::fromArray($students); + $expected = <<< EOT +- name: tony + phones: + - 1234-456 +- name: nick + phones: + - 1234-456 + - 456-4334 +EOT; + + $this->assertStringContainsString($expected, $Yaml); + } + + public function testFromArrayMultiYamls() + { + $students = [ + 'id' => 1234, + 'name' => 'tony', + 'addresess' => [ + ['street' => '1234 some road','city' => 'london'], + ['street' => '546 some avenue','city' => 'london'], + ], + ]; + $Yaml = Yaml::fromArray($students); + $expected = <<< EOT +id: 1234 +name: tony +addresess: + - street: 1234 some road + city: london + - street: 546 some avenue + city: london +EOT; + $this->assertStringContainsString($expected, $Yaml); + } + + public function testPlainScalarMultiline() + { + $Yaml = <<< EOF +multi: + a + b + c + d +name: test +EOF; + $this->assertEquals('a b c d', Yaml::toArray($Yaml)['multi']); + } + + public function testFromArrayMultiLevel() + { + $data = [ + 'services' => [ + 'app' => [ + 'build' => '.', + 'depends_on' => [ + 'db', + ], + ], + 'memcached' => [ + 'image' => 'memcached', + ], + ], + 'volumes' => [ + 'mysql' => 'abc', // leaving this blank is a problem. works with docker. but cant parse it + ], + ]; + $Yaml = Yaml::fromArray($data); + $expected = <<< EOT +services: + app: + build: . + depends_on: + - db + memcached: + image: memcached +volumes: + mysql: abc +EOT; + $this->assertStringContainsString($expected, $Yaml); + } + + public function testParseValues() + { + $Yaml = <<< EOF +enabled: true +disabled: false +empty: null +EOF; + $result = Yaml::toArray($Yaml); + $this->assertEquals(true, $result['enabled']); + $this->assertEquals(false, $result['disabled']); + $this->assertNull($result['empty']); + } + + public function testParseIndexedList() + { + $Yaml = <<< EOT +--- +# List of fruits +- + name: james +- + name: amy +EOT; + $expected = [['name' => 'james'],['name' => 'amy']]; + $this->assertEquals($expected, Yaml::toArray($Yaml)); + } + + public function testParseList() + { + $Yaml = <<< EOT +--- +# List of fruits +fruits: + - Apple + - Orange + - Banana +EOT; + $expected = ['fruits' => ['Apple','Orange','Banana']]; + $this->assertEquals($expected, Yaml::toArray($Yaml)); + } + + public function testParseDictonary() + { + $Yaml = <<< EOT +--- +# Employee record +employee: + name: James + position: Senior Developer +EOT; + + $expected = ['employee' => ['name' => 'James','position' => 'Senior Developer']]; + $this->assertEquals($expected, Yaml::toArray($Yaml)); + } + + public function testParseRecordSet() + { + $Yaml = <<< EOT +--- +# Employees +- 100: + name: James + position: Senior Developer +- 200: + name: Tony + position: Manager + +EOT; + $expected = [ + '100' => ['name' => 'James','position' => 'Senior Developer'], + '200' => ['name' => 'Tony','position' => 'Manager'], + ]; + $this->assertEquals($expected, Yaml::toArray($Yaml)); + } + + public function testParseMultiLineBlock() + { + $Yaml = <<< EOT +block_1: | + this is a multiline block + of text +block_2: > + this also is a multiline block + of text +EOT; + $expected = [ + 'block_1' => "this is a multiline block\nof text", // literal + 'block_2' => 'this also is a multiline block of text', // folded + ]; + $result = Yaml::toArray($Yaml); + + $this->assertSame($expected, Yaml::toArray($Yaml)); + } + + public function testComplicated() + { + $Yaml = <<< EOT +--- +# Employee record +name: James Anderson +job: PHP developer +active: true +fruits: + - Apple + - Banana +phones: + home: 0207 123 4567 + mobile: 123 456 567 +addresses: + - street: 2 Some road + city: London + - street: 5 Some avenue + city: Manchester +description: | + Lorem ipsum dolor sit amet, + ea eum nihil sapientem, timeam + constituto id per. +EOT; + $expected = '{"name":"James Anderson","job":"PHP developer","active":true,"fruits":["Apple","Banana"],"phones":{"home":"0207 123 4567","mobile":"123 456 567"},"addresses":[{"street":"2 Some road","city":"London"},{"street":"5 Some avenue","city":"Manchester"}],"description":"Lorem ipsum dolor sit amet,\nea eum nihil sapientem, timeam\nconstituto id per."}'; + $this->assertEquals($expected, json_encode(Yaml::toArray($Yaml))); + } + + public function testParseChildNumericalList() + { + $Yaml = <<< EOT +# Employee record +name: James +addresses: + - + city: London + - + city: Liverpool +EOT; + $expected = [['city' => 'London'],['city' => 'Liverpool']]; + $result = Yaml::toArray($Yaml); + + $this->assertSame($expected, $result['addresses']); + } + + public function testUsingTabsException() + { + $this->expectException(YamlException::class); + $Yaml = "\tname: no tab please"; + Yaml::toArray($Yaml); + } + + public function testMultiDocumentStreamException() + { + $this->expectException(YamlException::class); + $Yaml = "...\nname: value..."; + Yaml::toArray($Yaml); + } +}