Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Static analysis of the documentation's PHP examples against the real package.
#
# The paths are supplied on the command line by check-code-examples.php, which
# extracts the code blocks first. Reported line numbers are the markdown line
# numbers: the extractor blanks out everything that is not code rather than
# collapsing it.
parameters:
level: 5

scanFiles:
- stubs/placeholders.php

ignoreErrors:
# Examples routinely assign something to show the shape of the wiring
# without going on to use it — the Symfony page's constructor injection
# is the whole point of that snippet. This can never indicate a
# documentation defect.
- identifier: property.onlyWritten
160 changes: 160 additions & 0 deletions .github/scripts/extract-code-examples.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env php
<?php

/**
* Extracts the documentation's PHP code blocks into analysable files.
*
* Two things make the output more useful than a naive dump:
*
* 1. Every line keeps its markdown line number. Lines outside a code fence
* become blank lines, so PHPStan's "line 27" is line 27 of the markdown and
* no line map is needed to report a useful location.
* 2. Each page gets its own namespace. Several pages define their own `User`
* for the example to map onto, and analysing them together would otherwise
* collide.
*
* A page's fences are treated as one file, because pages routinely define the
* class in one fence and map onto it in the next.
*
* Usage: extract-code-examples.php <output-directory>
*/

declare(strict_types=1);

const SOURCES = ['_docs', 'resources/includes'];

$root = dirname(__DIR__, 2);
$outputDir = $argv[1] ?? null;

if ($outputDir === null) {
fwrite(STDERR, "usage: extract-code-examples.php <output-directory>\n");
exit(1);
}

/** A namespace segment per path component, so each page is isolated. */
function namespaceFor(string $relative): string
{
$parts = preg_split('#[/\\\\]#', substr($relative, 0, -3)) ?: [];
$parts = array_map(
static fn (string $part): string => str_replace(' ', '', ucwords(str_replace(['-', '_', '.'], ' ', $part))),
$parts
);

return 'DocExample\\' . implode('\\', $parts);
}

$files = [];
foreach (SOURCES as $source) {
if (! is_dir("$root/$source")) {
continue;
}
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator("$root/$source")) as $file) {
if ($file->isFile() && $file->getExtension() === 'md') {
$files[] = $file->getPathname();
}
}
}
sort($files);

$written = 0;
foreach ($files as $file) {
$relative = substr($file, strlen($root) + 1);
$lines = explode("\n", (string) file_get_contents($file));

$out = [];
$ownFiles = [];
$inFence = false;
$fence = [];
$fenceStart = 0;
$seenUse = [];

foreach ($lines as $index => $line) {
if (! $inFence) {
$out[] = '';
if (preg_match('/^```php\s*$/', $line)) {
$inFence = true;
$fence = [];
$fenceStart = $index + 1;
}
continue;
}

if (preg_match('/^```\s*$/', $line)) {
// A fence declaring its own namespace stands for a separate file in
// the reader's application. It gets its own analysed file, keeping
// the namespace it declares, rather than being merged into the page.
$isOwnFile = false;
foreach ($fence as $fenceLine) {
if (preg_match('/^\s*namespace\s+/', $fenceLine)) {
$isOwnFile = true;
break;
}
}

if ($isOwnFile) {
$standalone = array_fill(0, count($lines), '');
foreach ($fence as $offset => $fenceLine) {
$standalone[$fenceStart + $offset] = preg_match('/^<\?php\s*$/', trim($fenceLine))
? ''
: $fenceLine;
}
$standalone[0] = '<?php declare(strict_types=1);';
$ownFiles[] = implode("\n", $standalone);
}

foreach ($fence as $offset => $fenceLine) {
$keep = ! $isOwnFile;

if ($keep && preg_match('/^<\?php\s*$/', trim($fenceLine))) {
$keep = false;
}

// Repeating a `use` across fences of one page is correct in the
// docs but a redeclaration once merged.
if ($keep && preg_match('/^use\s+[^;]+;$/', trim($fenceLine))) {
if (isset($seenUse[trim($fenceLine)])) {
$keep = false;
} else {
$seenUse[trim($fenceLine)] = true;
}
}

$out[$fenceStart + $offset] = $keep ? $fenceLine : '';
}

$out[] = '';
$inFence = false;
continue;
}

$fence[] = $line;
$out[] = '';
}

// Mirror the source tree so a reported path maps straight back to the page.
foreach ($ownFiles as $index => $contents) {
$target = $outputDir . '/' . $relative . '.' . $index . '.php';
if (! is_dir(dirname($target))) {
mkdir(dirname($target), 0777, true);
}
file_put_contents($target, $contents);
$written++;
}

if (trim(implode('', $out)) === '') {
continue;
}

// Line 1 of a markdown page is front matter or a fence marker, never code,
// so the declaration can live there without shifting anything.
$out[0] = '<?php declare(strict_types=1); namespace ' . namespaceFor($relative) . ';';

$target = $outputDir . '/' . $relative . '.php';
if (! is_dir(dirname($target))) {
mkdir(dirname($target), 0777, true);
}
file_put_contents($target, implode("\n", $out));
$written++;
}

echo "Extracted {$written} files of code blocks into {$outputDir}\n";
149 changes: 149 additions & 0 deletions .github/scripts/lint-code-examples.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env php
<?php

/**
* Lints the PHP code blocks embedded in the documentation.
*
* The package this site documents lives in a different repository, so nothing
* here compiles the examples and a broken snippet ships unnoticed. Several did:
* a builder constructed with a method that does not exist, a middleware missing
* a required constructor argument, a mapper method handed a string where it
* wants a \stdClass. Parse errors are the subset a linter can catch without the
* package installed, so that is what this covers — it will not tell you an
* example is wrong, only that it is not valid PHP.
*
* Each fenced block is linted on its own, because that is how a reader meets it.
* Failures are reported against the markdown file and the line the fence starts
* on, not the temporary file the linter actually saw.
*/

declare(strict_types=1);

const SOURCES = ['_docs', 'resources/includes'];

$root = dirname(__DIR__, 2);
$onCi = getenv('GITHUB_ACTIONS') === 'true';

/** Every markdown file under the documented source directories. */
function markdownFiles(string $root): array
{
$files = [];
foreach (SOURCES as $source) {
$path = $root . '/' . $source;
if (! is_dir($path)) {
continue;
}
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'md') {
$files[] = $file->getPathname();
}
}
}
sort($files);

return $files;
}

/**
* Pull the ```php fences out of one file.
*
* @return array<int, array{line: int, code: string}> line is 1-indexed and
* points at the fence marker itself.
*/
function phpBlocks(string $markdown): array
{
$blocks = [];
$lines = explode("\n", $markdown);
$open = null;
$body = [];

foreach ($lines as $index => $line) {
if ($open === null) {
if (preg_match('/^```php\s*$/', $line)) {
$open = $index + 1;
$body = [];
}
continue;
}

if (preg_match('/^```\s*$/', $line)) {
$blocks[] = ['line' => $open, 'code' => implode("\n", $body)];
$open = null;
continue;
}

$body[] = $line;
}

return $blocks;
}

$files = markdownFiles($root);
$checked = 0;
$failures = [];

foreach ($files as $file) {
$relative = substr($file, strlen($root) + 1);

foreach (phpBlocks((string) file_get_contents($file)) as $block) {
$code = ltrim($block['code'], "\n");

// A block may or may not open with its own tag. Normalise to exactly one,
// and remember whether that shifted the code down a line so the linter's
// line numbers can be translated back to the markdown.
if (preg_match('/^<\?php\s*$/m', strtok($code, "\n") ?: '')) {
$offset = $block['line'];
} else {
$code = "<?php\n" . $code;
$offset = $block['line'] - 1;
}

$temp = tempnam(sys_get_temp_dir(), 'doc-example-') . '.php';
file_put_contents($temp, $code);

exec(sprintf('%s -l %s 2>&1', escapeshellarg(PHP_BINARY), escapeshellarg($temp)), $output, $status);
unlink($temp);
$checked++;

if ($status === 0) {
$output = [];
continue;
}

// php -l prints two lines: the diagnostic, then "Errors parsing <file>".
// Keep the first, and translate its line number back into the markdown.
$message = '';
foreach ($output as $candidate) {
if (str_contains($candidate, ' on line ')) {
$message = $candidate;
break;
}
}
$message = $message !== '' ? $message : ($output[0] ?? 'could not be parsed');
$output = [];

$line = $block['line'];
if (preg_match('/ on line (\d+)/', $message, $matches)) {
$line = $offset + (int) $matches[1];
}
$message = preg_replace('/ in \S+ on line \d+/', '', $message) ?? $message;
$message = trim(preg_replace('/^(PHP )?(Parse|Fatal) error:\s*/i', '', $message) ?? $message);

$failures[] = ['file' => $relative, 'line' => $line, 'message' => $message];
}
}

foreach ($failures as $failure) {
$text = sprintf('%s:%d %s', $failure['file'], $failure['line'], $failure['message']);
echo $onCi
? sprintf("::error file=%s,line=%d::%s\n", $failure['file'], $failure['line'], $failure['message'])
: $text . "\n";
}

if ($failures !== []) {
printf("\n%d of %d code blocks in %d files failed to parse.\n", count($failures), $checked, count($files));
exit(1);
}

printf("All %d PHP code blocks in %d files parse cleanly.\n", $checked, count($files));
Loading
Loading