Completed
Push — master ( c22443...98b774 )
by
unknown
18:25
created

DocumentationFile::findDocumentationDirectories()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 23
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 3
nop 1
dl 0
loc 23
rs 9.7666
c 0
b 0
f 0
1
<?php
2
declare(strict_types = 1);
3
4
namespace TYPO3\CMS\Install\UpgradeAnalysis;
5
6
/*
7
 * This file is part of the TYPO3 CMS project.
8
 *
9
 * It is free software; you can redistribute it and/or modify it under
10
 * the terms of the GNU General Public License, either version 2
11
 * of the License, or any later version.
12
 *
13
 * For the full copyright and license information, please read the
14
 * LICENSE.txt file that was distributed with this source code.
15
 *
16
 * The TYPO3 project - inspiring people to share!
17
 */
18
19
use Symfony\Component\Finder\Finder;
20
use Symfony\Component\Finder\SplFileInfo;
21
use TYPO3\CMS\Core\Registry;
22
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
23
use TYPO3\CMS\Core\Utility\GeneralUtility;
24
use TYPO3\CMS\Core\Utility\PathUtility;
25
use TYPO3\CMS\Core\Utility\VersionNumberUtility;
26
27
/**
28
 * Provide information about documentation files
29
 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
30
 */
31
class DocumentationFile
32
{
33
    /**
34
     * @var Registry
35
     */
36
    protected $registry;
37
38
    /**
39
     * @var array Unified array of used tags
40
     */
41
    protected $tagsTotal = [];
42
43
    /**
44
     * all files handled in this Class need to reside inside the changelog dir
45
     * this is a security measure to protect system files
46
     *
47
     * @var string
48
     */
49
    protected $changelogPath = '';
50
51
    /**
52
     * DocumentationFile constructor.
53
     * @param Registry|null $registry
54
     * @param string $changelogDir
55
     */
56
    public function __construct(Registry $registry = null, $changelogDir = '')
57
    {
58
        $this->registry = $registry;
59
        if ($this->registry === null) {
60
            $this->registry = new Registry();
61
        }
62
        $this->changelogPath = $changelogDir !== '' ? $changelogDir : realpath(ExtensionManagementUtility::extPath('core') . 'Documentation/Changelog');
63
        $this->changelogPath = str_replace('\\', '/', $this->changelogPath);
64
    }
65
66
    /**
67
     * Traverse given directory, select directories
68
     *
69
     * @param string $path
70
     * @return string[] Version directories
71
     * @throws \InvalidArgumentException
72
     */
73
    public function findDocumentationDirectories(string $path): array
74
    {
75
        if (strcasecmp($path, $this->changelogPath) < 0 || strpos($path, $this->changelogPath) === false) {
76
            throw new \InvalidArgumentException('the given path does not belong to the changelog dir. Aborting', 1537158043);
77
        }
78
79
        $currentVersion = (int)explode('.', VersionNumberUtility::getNumericTypo3Version())[0];
80
        $versions = range($currentVersion, $currentVersion - 2);
81
        $pattern = '(master|' . implode('\.*|', $versions) . '\.*)';
82
        $finder = new Finder();
83
        $finder
84
            ->depth(0)
85
            ->sortByName(true)
86
            ->name($pattern)
87
            ->in($path);
88
89
        $directories = [];
90
        foreach ($finder->directories() as $directory) {
91
            /** @var SplFileInfo $directory */
92
            $directories[] = $directory->getBasename();
93
        }
94
95
        return $directories;
96
    }
97
98
    /**
99
     * Traverse given directory, select files
100
     *
101
     * @param string $path
102
     * @return array file details of affected documentation files
103
     * @throws \InvalidArgumentException
104
     */
105
    public function findDocumentationFiles(string $path): array
106
    {
107
        if (strcasecmp($path, $this->changelogPath) < 0 || strpos($path, $this->changelogPath) === false) {
108
            throw new \InvalidArgumentException('the given path does not belong to the changelog dir. Aborting', 1485425530);
109
        }
110
111
        $documentationFiles = $this->getDocumentationFilesForVersion($path);
112
        $this->tagsTotal = $this->collectTagTotal($documentationFiles);
113
114
        return $documentationFiles;
115
    }
116
117
    /**
118
     * Get main information from a .rst file
119
     *
120
     * @param string $file Absolute path to documentation file
121
     * @return array
122
     * @throws \InvalidArgumentException
123
     */
124
    public function getListEntry(string $file): array
125
    {
126
        $entry = [];
127
        if (strcasecmp($file, $this->changelogPath) < 0 || strpos($file, $this->changelogPath) === false) {
128
            throw new \InvalidArgumentException('the given file does not belong to the changelog dir. Aborting', 1485425531);
129
        }
130
        $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
131
        $headline = $this->extractHeadline($lines);
0 ignored issues
show
Bug introduced by
It seems like $lines can also be of type false; however, parameter $lines of TYPO3\CMS\Install\Upgrad...File::extractHeadline() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

131
        $headline = $this->extractHeadline(/** @scrutinizer ignore-type */ $lines);
Loading history...
132
        $entry['version'] = PathUtility::basename(PathUtility::dirname($file));
133
        $entry['headline'] = $headline;
134
        $entry['filepath'] = $file;
135
        $entry['filename'] = pathinfo($file)['filename'];
136
        $entry['tags'] = $this->extractTags($lines);
0 ignored issues
show
Bug introduced by
It seems like $lines can also be of type false; however, parameter $file of TYPO3\CMS\Install\Upgrad...tionFile::extractTags() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

136
        $entry['tags'] = $this->extractTags(/** @scrutinizer ignore-type */ $lines);
Loading history...
137
        $entry['class'] = 'default';
138
        foreach ($entry['tags'] as $key => $tag) {
139
            if (strpos($tag, 'cat:') === 0) {
140
                $substr = substr($tag, 4);
141
                $entry['class'] = strtolower($substr);
142
                $entry['tags'][$key] = $substr;
143
            }
144
        }
145
        $entry['tagList'] = implode(',', $entry['tags']);
146
        $entry['content'] = file_get_contents($file);
147
        $entry['parsedContent'] = $this->parseContent($entry['content']);
148
        $entry['file_hash'] = md5($entry['content']);
149
        if ($entry['version'] !== '') {
150
            $entry['url']['documentation'] = sprintf(
151
                'https://docs.typo3.org/c/typo3/cms-core/master/en-us/Changelog/%s/%s.html',
152
                $entry['version'],
153
                $entry['filename']
154
            );
155
        }
156
        $entry['url']['issue'] = sprintf(
157
            'https://forge.typo3.org/issues/%s',
158
            $this->parseIssueId($entry['filename'])
159
        );
160
161
        return [md5($file) => $entry];
162
    }
163
164
    /**
165
     * True if file should be considered
166
     *
167
     * @param array $fileInfo
168
     * @return bool
169
     */
170
    protected function isRelevantFile(array $fileInfo): bool
171
    {
172
        $isRelevantFile = $fileInfo['extension'] === 'rst' && $fileInfo['filename'] !== 'Index';
173
        // file might be ignored by users choice
174
        if ($isRelevantFile && $this->isFileIgnoredByUsersChoice($fileInfo['basename'])) {
175
            $isRelevantFile = false;
176
        }
177
178
        return $isRelevantFile;
179
    }
180
181
    /**
182
     * Add tags from file
183
     *
184
     * @param array $file file content, each line is an array item
185
     * @return array
186
     */
187
    protected function extractTags(array $file): array
188
    {
189
        $tags = $this->extractTagsFromFile($file);
190
        // Headline starting with the category like Breaking, Important or Feature
191
        $tags[] = $this->extractCategoryFromHeadline($file);
192
        natcasesort($tags);
193
194
        return $tags;
195
    }
196
197
    /**
198
     * Files must contain an index entry, detailing any number of manual tags
199
     * each of these tags is extracted and added to the general tag structure for the file
200
     *
201
     * @param array $file file content, each line is an array item
202
     * @return array extracted tags
203
     */
204
    protected function extractTagsFromFile(array $file): array
205
    {
206
        foreach ($file as $line) {
207
            if (strpos($line, '.. index::') === 0) {
208
                $tagString = substr($line, strlen('.. index:: '));
209
                return GeneralUtility::trimExplode(',', $tagString, true);
210
            }
211
        }
212
213
        return [];
214
    }
215
216
    /**
217
     * Files contain a headline (provided as input parameter,
218
     * it starts with the category string.
219
     * This will used as a tag
220
     *
221
     * @param array $lines
222
     * @return string
223
     */
224
    protected function extractCategoryFromHeadline(array $lines): string
225
    {
226
        $headline = $this->extractHeadline($lines);
227
        if (strpos($headline, ':') !== false) {
228
            return 'cat:' . substr($headline, 0, strpos($headline, ':'));
229
        }
230
231
        return '';
232
    }
233
234
    /**
235
     * Skip include line and markers, use the first line actually containing text
236
     *
237
     * @param array $lines
238
     * @return string
239
     */
240
    protected function extractHeadline(array $lines): string
241
    {
242
        $index = 0;
243
        while (strpos($lines[$index], '..') === 0 || strpos($lines[$index], '==') === 0) {
244
            $index++;
245
        }
246
        return trim($lines[$index]);
247
    }
248
249
    /**
250
     * @param string $docDirectory
251
     * @param string $version
252
     * @return bool
253
     */
254
    protected function versionHasDocumentationFiles(string $docDirectory, string $version): bool
255
    {
256
        $absolutePath = str_replace('\\', '/', $docDirectory) . '/' . $version;
257
        $finder = $this->getDocumentFinder()->in($absolutePath);
258
259
        return $finder->files()->count() > 0;
260
    }
261
262
    /**
263
     * Handle a single directory
264
     *
265
     * @param string $docDirectory
266
     * @return array
267
     */
268
    protected function getDocumentationFilesForVersion(string $docDirectory): array
269
    {
270
        $documentationFiles = [[]];
271
        $absolutePath = str_replace('\\', '/', $docDirectory);
272
        $finder = $this->getDocumentFinder()->in($absolutePath);
273
274
        foreach ($finder->files() as $file) {
275
            /** @var SplFileInfo $file */
276
            $documentationFiles[] = $this->getListEntry($file->getPathname());
277
        }
278
279
        return array_merge(...$documentationFiles);
280
    }
281
282
    /**
283
     * Merge tag list
284
     *
285
     * @param iterable $documentationFiles
286
     * @return array
287
     */
288
    protected function collectTagTotal($documentationFiles): array
289
    {
290
        $tags = [[]];
291
        foreach ($documentationFiles as $fileArray) {
292
            $tags[] = $fileArray['tags'];
293
        }
294
295
        return array_unique(array_merge(...$tags));
296
    }
297
298
    /**
299
     * Return full tag list
300
     *
301
     * @return array
302
     */
303
    public function getTagsTotal(): array
304
    {
305
        return $this->tagsTotal;
306
    }
307
308
    /**
309
     * whether that file has been removed from users view
310
     *
311
     * @param string $filename
312
     * @return bool
313
     */
314
    protected function isFileIgnoredByUsersChoice(string $filename): bool
315
    {
316
        $isFileIgnoredByUsersChoice = false;
317
318
        $ignoredFiles = $this->registry->get('upgradeAnalysisIgnoreFilter', 'ignoredDocumentationFiles');
319
        if (is_array($ignoredFiles)) {
320
            foreach ($ignoredFiles as $filePath) {
321
                if ($filePath !== null && strlen($filePath) > 0) {
322
                    if (strpos($filePath, $filename) !== false) {
323
                        $isFileIgnoredByUsersChoice = true;
324
                        break;
325
                    }
326
                }
327
            }
328
        }
329
        return $isFileIgnoredByUsersChoice;
330
    }
331
332
    /**
333
     * @param string $rstContent
334
     *
335
     * @return string
336
     */
337
    protected function parseContent(string $rstContent): string
338
    {
339
        $content = htmlspecialchars($rstContent);
340
        $content = preg_replace('/:issue:`([\d]*)`/', '<a href="https://forge.typo3.org/issues/\\1" target="_blank" rel="noreferrer">\\1</a>', $content);
341
        $content = preg_replace('/#([\d]*)/', '#<a href="https://forge.typo3.org/issues/\\1" target="_blank" rel="noreferrer">\\1</a>', $content);
342
        $content = preg_replace('/(\n([=]*)\n(.*)\n([=]*)\n)/', '', $content, 1);
343
        $content = preg_replace('/.. index::(.*)/', '', $content);
344
        $content = preg_replace('/.. include::(.*)/', '', $content);
345
        return trim($content);
346
    }
347
348
    /**
349
     * @param string $filename
350
     *
351
     * @return string
352
     */
353
    protected function parseIssueId(string $filename): string
354
    {
355
        return GeneralUtility::trimExplode('-', $filename)[1];
356
    }
357
358
    /**
359
     * @return Finder
360
     */
361
    protected function getDocumentFinder(): Finder
362
    {
363
        $finder = new Finder();
364
        $finder
365
            ->depth(0)
366
            ->sortByName()
367
            ->name('/^(Feature|Breaking|Deprecation|Important)\-\d+.+\.rst$/i');
368
369
        return $finder;
370
    }
371
}
372