Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
181 / 181
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
KeepAChangelogParser
100.00% covered (success)
100.00%
181 / 181
100.00% covered (success)
100.00%
5 / 5
55
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 endsInLink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 split
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 parse
100.00% covered (success)
100.00%
110 / 110
100.00% covered (success)
100.00%
1 / 1
27
 format
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
17
1<?php
2/**
3 * Parser for a keepachangelog.com format changelog.
4 *
5 * @package automattic/jetpack-changelogger
6 */
7
8namespace Automattic\Jetpack\Changelog;
9
10use DateTime;
11use DateTimeZone;
12use InvalidArgumentException;
13
14/**
15 * Parser for a keepachangelog.com format changelog.
16 */
17class 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}