Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
181 / 181 |
|
100.00% |
5 / 5 |
CRAP | |
100.00% |
1 / 1 |
| KeepAChangelogParser | |
100.00% |
181 / 181 |
|
100.00% |
5 / 5 |
55 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| endsInLink | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| split | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| parse | |
100.00% |
110 / 110 |
|
100.00% |
1 / 1 |
27 | |||
| format | |
100.00% |
46 / 46 |
|
100.00% |
1 / 1 |
17 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Parser for a keepachangelog.com format changelog. |
| 4 | * |
| 5 | * @package automattic/jetpack-changelogger |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Changelog; |
| 9 | |
| 10 | use DateTime; |
| 11 | use DateTimeZone; |
| 12 | use InvalidArgumentException; |
| 13 | |
| 14 | /** |
| 15 | * Parser for a keepachangelog.com format changelog. |
| 16 | */ |
| 17 | class KeepAChangelogParser extends Parser { |
| 18 | |
| 19 | /** |
| 20 | * Bullet for changes. |
| 21 | * |
| 22 | * @var string |
| 23 | */ |
| 24 | private $bullet = '-'; |
| 25 | |
| 26 | /** |
| 27 | * Output date format. |
| 28 | * |
| 29 | * @var string |
| 30 | */ |
| 31 | private $dateFormat = 'Y-m-d'; |
| 32 | |
| 33 | /** |
| 34 | * String used as the date for an unreleased version. |
| 35 | * |
| 36 | * @var string |
| 37 | */ |
| 38 | private $unreleased = 'unreleased'; |
| 39 | |
| 40 | /** |
| 41 | * If true, try to parse authors from entries. |
| 42 | * |
| 43 | * @var bool |
| 44 | */ |
| 45 | private $parseAuthors = false; |
| 46 | |
| 47 | /** |
| 48 | * Constructor. |
| 49 | * |
| 50 | * @param array $config Configuration. |
| 51 | * - bullet: (string) Bullet for changes. Default '-'. |
| 52 | * - dateFormat: (string) Date format to use in output. Default 'Y-m-d'. |
| 53 | * - parseAuthors: (bool) Try to parse authors out of change entries. Default false. |
| 54 | * - unreleased: (string) String used as the date for an unreleased version. |
| 55 | */ |
| 56 | public function __construct( array $config = array() ) { |
| 57 | if ( ! empty( $config['bullet'] ) ) { |
| 58 | $this->bullet = $config['bullet']; |
| 59 | } |
| 60 | if ( ! empty( $config['dateFormat'] ) ) { |
| 61 | $this->dateFormat = $config['dateFormat']; |
| 62 | } |
| 63 | $this->parseAuthors = ! empty( $config['parseAuthors'] ); |
| 64 | if ( ! empty( $config['unreleased'] ) ) { |
| 65 | $this->unreleased = $config['unreleased']; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Test if there's a link at the end of a string. |
| 71 | * |
| 72 | * @param string $s String. |
| 73 | * @return array|null Match data. |
| 74 | */ |
| 75 | private function endsInLink( $s ) { |
| 76 | if ( preg_match( '/^\[([^]]+)\]: *(\S+(?: +(?:"(?:[^"]|\\\\.)*"|\'(?:[^\']|\\\\.)*\'|\((?:[^()]|\\\\.)*\)))?)\s*\Z/m', $s, $m ) ) { |
| 77 | return array( |
| 78 | 'match' => $m[0], |
| 79 | 'id' => $m[1], |
| 80 | 'link' => $m[2], |
| 81 | ); |
| 82 | } |
| 83 | return null; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Split a string in two at the first occurrence of a substring. |
| 88 | * |
| 89 | * @param string $haystack String to split. |
| 90 | * @param string ...$needles Strings to split on. Earliest match in $haystack wins. |
| 91 | * @return string[] Two elements: The part before $needles and the part after, both trimmed. |
| 92 | */ |
| 93 | private function split( $haystack, ...$needles ) { |
| 94 | $i = false; |
| 95 | foreach ( $needles as $needle ) { |
| 96 | $j = strpos( $haystack, $needle ); |
| 97 | if ( false !== $j ) { |
| 98 | $i = false === $i ? $j : min( $i, $j ); |
| 99 | } |
| 100 | } |
| 101 | if ( false === $i ) { |
| 102 | return array( trim( $haystack, "\n" ), '' ); |
| 103 | } |
| 104 | return array( |
| 105 | trim( substr( $haystack, 0, $i ), "\n" ), |
| 106 | trim( substr( $haystack, $i ), "\n" ), |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Parse changelog data into a Changelog object. |
| 112 | * |
| 113 | * This does not handle all markdown! In particular, it makes the following assumptions: |
| 114 | * |
| 115 | * - All level-2 ATX headings with no indentation are changelog entry headings. |
| 116 | * - Changelog entry headings consist of either a bare version number or a version number as |
| 117 | * link text with no destination or title, followed by a spaced ASCII hyphen, followed by a timestamp. |
| 118 | * - All level-3 ATX headings with no indentation are changelog entry subheadings. |
| 119 | * - All change entries are formatted as lists starting with the configured bullet followed by a space, |
| 120 | * and do not make use of lazy continuation. Indentation of continued |
| 121 | * lines is equal to the length of the bullet plus the space. |
| 122 | * - All link definitions come at the end of the document, with no intervening blank lines or |
| 123 | * other content, and are not indented and do not contain newlines. Link definitions for |
| 124 | * changelog entries have no titles. |
| 125 | * |
| 126 | * @param string $changelog Changelog contents. |
| 127 | * @return Changelog |
| 128 | * @throws InvalidArgumentException If the changelog data cannot be parsed. |
| 129 | */ |
| 130 | public function parse( $changelog ) { |
| 131 | $ret = new Changelog(); |
| 132 | |
| 133 | $bullet = $this->bullet . ' '; |
| 134 | $len = strlen( $bullet ); |
| 135 | $indent = str_repeat( ' ', $len ); |
| 136 | |
| 137 | // Fix newlines and expand tabs. |
| 138 | $changelog = strtr( $changelog, array( "\r\n" => "\n" ) ); |
| 139 | $changelog = strtr( $changelog, array( "\r" => "\n" ) ); |
| 140 | while ( strpos( $changelog, "\t" ) !== false ) { |
| 141 | $changelog = preg_replace_callback( |
| 142 | '/^([^\t\n]*)\t/m', |
| 143 | function ( $m ) { |
| 144 | return $m[1] . str_repeat( ' ', 4 - ( mb_strlen( $m[1] ) % 4 ) ); |
| 145 | }, |
| 146 | $changelog |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | // Extract link definitions. |
| 151 | $links = array(); |
| 152 | $usedlinks = array(); |
| 153 | while ( ( $m = $this->endsInLink( $changelog ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition |
| 154 | $links[ $m['id'] ] = $m['link']; |
| 155 | $usedlinks[ $m['id'] ] = false; |
| 156 | $changelog = (string) substr( $changelog, 0, -strlen( $m['match'] ) ); |
| 157 | } |
| 158 | $links = array_reverse( $links ); |
| 159 | |
| 160 | // Everything up to the first level-2 ATX heading is the prologue. |
| 161 | list( $prologue, $changelog ) = $this->split( "\n$changelog", "\n## " ); |
| 162 | $ret->setPrologue( $prologue ); |
| 163 | |
| 164 | // Entries make up the rest of the document. |
| 165 | $entries = array(); |
| 166 | while ( '' !== $changelog ) { |
| 167 | // Extract the first entry from the changelog file, then extract the heading from it. |
| 168 | list( $content, $changelog ) = $this->split( $changelog, "\n## " ); |
| 169 | list( $heading, $content ) = $this->split( $content, "\n" ); |
| 170 | |
| 171 | // Parse the heading and create a ChangelogEntry for it. |
| 172 | if ( ! preg_match( '/^## +(\[?[^] ]+\]?) - (.+?) *$/', $heading, $m ) ) { |
| 173 | throw new InvalidArgumentException( "Invalid heading: $heading" ); |
| 174 | } |
| 175 | $link = null; |
| 176 | $version = $m[1]; |
| 177 | $timestamp = $m[2]; |
| 178 | if ( '[' === $version[0] && ']' === substr( $version, -1 ) ) { |
| 179 | $version = substr( $version, 1, -1 ); |
| 180 | if ( ! isset( $links[ $version ] ) ) { |
| 181 | throw new InvalidArgumentException( "Heading seems to have a linked version, but link was not found: $heading. Please ensure this version has a URL defined at the end of the changelog." ); |
| 182 | } |
| 183 | $link = $links[ $version ]; |
| 184 | $usedlinks[ $version ] = true; |
| 185 | } |
| 186 | if ( $timestamp === $this->unreleased ) { |
| 187 | $timestamp = null; |
| 188 | $entryTimestamp = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); |
| 189 | } else { |
| 190 | try { |
| 191 | $timestamp = new DateTime( $timestamp, new DateTimeZone( 'UTC' ) ); |
| 192 | } catch ( \Exception $ex ) { |
| 193 | throw new InvalidArgumentException( "Heading has an invalid timestamp: $heading", 0, $ex ); |
| 194 | } |
| 195 | if ( strtotime( $m[2], 0 ) !== strtotime( $m[2], 1000000000 ) ) { |
| 196 | throw new InvalidArgumentException( "Heading has a relative timestamp: $heading" ); |
| 197 | } |
| 198 | $entryTimestamp = $timestamp; |
| 199 | } |
| 200 | $entry = $this->newChangelogEntry( |
| 201 | $version, |
| 202 | array( |
| 203 | 'link' => $link, |
| 204 | 'timestamp' => $timestamp, |
| 205 | ) |
| 206 | ); |
| 207 | $entries[] = $entry; |
| 208 | |
| 209 | // Extract the prologue, if any. |
| 210 | list( $prologue, $content ) = $this->split( "\n$content", "\n### ", "\n$bullet" ); |
| 211 | $entry->setPrologue( $prologue ); |
| 212 | |
| 213 | if ( '' === $content ) { |
| 214 | // Huh, no changes. |
| 215 | continue; |
| 216 | } |
| 217 | |
| 218 | // Inject an empty heading if necessary so the change parsing can be more straightforward. |
| 219 | if ( '#' !== $content[0] ) { |
| 220 | $content = "### \n$content"; |
| 221 | } |
| 222 | |
| 223 | // Now parse all the subheadings and changes. |
| 224 | while ( '' !== $content ) { |
| 225 | list( $section, $content ) = $this->split( $content, "\n### " ); |
| 226 | list( $subheading, $section ) = $this->split( $section, "\n" ); |
| 227 | $subheading = trim( substr( $subheading, 4 ) ); |
| 228 | $changes = array(); |
| 229 | $cur = ''; |
| 230 | $section = explode( "\n", $section ); |
| 231 | while ( $section ) { |
| 232 | $line = array_shift( $section ); |
| 233 | $prefix = substr( $line, 0, $len ); |
| 234 | if ( $prefix === $bullet ) { |
| 235 | $cur = trim( $cur ); |
| 236 | if ( '' !== $cur ) { |
| 237 | $changes[] = $cur; |
| 238 | } |
| 239 | $cur = substr( $line, $len ) . "\n"; |
| 240 | } elseif ( $prefix === $indent ) { |
| 241 | $cur .= substr( $line, $len ) . "\n"; |
| 242 | } elseif ( '' === $line ) { |
| 243 | $cur .= "\n"; |
| 244 | } else { |
| 245 | // If there are no more subsections and the rest of the lines don't contain |
| 246 | // bullets, assume it's an epilogue. Otherwise, assume it's an error. |
| 247 | $section = $line . "\n" . implode( "\n", $section ); |
| 248 | if ( '' === $content && strpos( $section, "\n$bullet" ) === false ) { |
| 249 | $entry->setEpilogue( $section ); |
| 250 | break; |
| 251 | } else { |
| 252 | throw new InvalidArgumentException( "Malformatted changes list near \"$line\"" ); |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | $cur = trim( $cur ); |
| 257 | if ( '' !== $cur ) { |
| 258 | $changes[] = $cur; |
| 259 | } |
| 260 | foreach ( $changes as $change ) { |
| 261 | $author = ''; |
| 262 | if ( $this->parseAuthors && preg_match( '/ \(([^()\n]+)\)$/', $change, $m ) ) { |
| 263 | $author = $m[1]; |
| 264 | $change = substr( $change, 0, -strlen( $m[0] ) ); |
| 265 | } |
| 266 | $entry->appendChange( |
| 267 | $this->newChangeEntry( |
| 268 | array( |
| 269 | 'subheading' => $subheading, |
| 270 | 'author' => $author, |
| 271 | 'content' => $change, |
| 272 | 'timestamp' => $entryTimestamp, |
| 273 | ) |
| 274 | ) |
| 275 | ); |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | $ret->setEntries( $entries ); |
| 280 | |
| 281 | // Append any unused links to the epilogue. |
| 282 | $epilogue = $ret->getEpilogue(); |
| 283 | foreach ( $links as $id => $content ) { |
| 284 | if ( empty( $usedlinks[ $id ] ) ) { |
| 285 | $epilogue .= "\n[$id]: $content"; |
| 286 | } |
| 287 | } |
| 288 | $ret->setEpilogue( $epilogue ); |
| 289 | |
| 290 | return $ret; |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Write a Changelog object to a string. |
| 295 | * |
| 296 | * @param Changelog $changelog Changelog object. |
| 297 | * @return string |
| 298 | */ |
| 299 | public function format( Changelog $changelog ) { |
| 300 | $ret = ''; |
| 301 | |
| 302 | $bullet = $this->bullet . ' '; |
| 303 | $indent = str_repeat( ' ', strlen( $bullet ) ); |
| 304 | |
| 305 | $prologue = trim( $changelog->getPrologue() ); |
| 306 | if ( '' !== $prologue ) { |
| 307 | $ret .= "$prologue\n\n"; |
| 308 | } |
| 309 | |
| 310 | $links = array(); |
| 311 | foreach ( $changelog->getEntries() as $entry ) { |
| 312 | $ret .= '## '; |
| 313 | if ( $entry->getLink() !== null ) { |
| 314 | $links[ $entry->getVersion() ] = $entry->getLink(); |
| 315 | $ret .= "[{$entry->getVersion()}]"; |
| 316 | } else { |
| 317 | $ret .= $entry->getVersion(); |
| 318 | } |
| 319 | $timestamp = $entry->getTimestamp(); |
| 320 | $ret .= ' - ' . ( null === $timestamp ? $this->unreleased : $timestamp->format( $this->dateFormat ) ) . "\n"; |
| 321 | |
| 322 | $prologue = trim( $entry->getPrologue() ); |
| 323 | if ( '' !== $prologue ) { |
| 324 | $ret .= "\n$prologue\n\n"; |
| 325 | } |
| 326 | |
| 327 | foreach ( $entry->getChangesBySubheading() as $heading => $changes ) { |
| 328 | if ( '' !== $heading ) { |
| 329 | $heading = "### $heading\n"; |
| 330 | } else { |
| 331 | $heading = substr( $ret, -2 ) === "\n\n" ? '' : "\n"; |
| 332 | } |
| 333 | $post = ''; |
| 334 | foreach ( $changes as $change ) { |
| 335 | $text = trim( $change->getContent() ); |
| 336 | if ( '' !== $text ) { |
| 337 | if ( $change->getAuthor() !== '' ) { |
| 338 | $text .= " ({$change->getAuthor()})"; |
| 339 | } |
| 340 | $ret .= $heading . $bullet . str_replace( "\n", "\n$indent", $text ) . "\n"; |
| 341 | $heading = ''; |
| 342 | $post = "\n"; |
| 343 | } |
| 344 | } |
| 345 | $ret .= $post; |
| 346 | } |
| 347 | |
| 348 | $epilogue = trim( $entry->getEpilogue() ); |
| 349 | if ( '' !== $epilogue ) { |
| 350 | $ret = trim( $ret ) . "\n\n$epilogue\n"; |
| 351 | } |
| 352 | $ret = trim( $ret ) . "\n\n"; |
| 353 | } |
| 354 | |
| 355 | $epilogue = trim( $changelog->getEpilogue() ); |
| 356 | if ( '' !== $epilogue ) { |
| 357 | $ret .= "$epilogue\n"; |
| 358 | } |
| 359 | |
| 360 | $ret = trim( $ret ) . "\n"; |
| 361 | |
| 362 | if ( $links ) { |
| 363 | if ( ! $this->endsInLink( $ret ) ) { |
| 364 | $ret .= "\n"; |
| 365 | } |
| 366 | foreach ( $links as $k => $v ) { |
| 367 | $ret .= "[$k]: $v\n"; |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | return $ret; |
| 372 | } |
| 373 | } |