packages/sql-catalog/tests/Unit/Reporter/Html/Source/SourceCodeTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Source;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Sql\StatementKind;
15use SqlCatalog\Core\Text\LiteralText;
16use SqlCatalog\Core\Text\TextPattern;
17use SqlCatalog\Reporter\Html\CatalogIndex;
18use SqlCatalog\Reporter\Html\CatalogStatistics;
19use SqlCatalog\Reporter\Html\HtmlText;
20use SqlCatalog\Reporter\Html\ReportSite;
21use SqlCatalog\Reporter\Html\Scope;
22use SqlCatalog\Reporter\Html\Source\SourceCode;
23
24#[CoversClass(SourceCode::class)]
25#[UsesClass(CallSite::class)]
26#[UsesClass(Catalog::class)]
27#[UsesClass(CatalogEntry::class)]
28#[UsesClass(CatalogIndex::class)]
29#[UsesClass(CatalogStatistics::class)]
30#[UsesClass(HtmlText::class)]
31#[UsesClass(ReportSite::class)]
32#[UsesClass(Scope::class)]
33#[UsesClass(LiteralText::class)]
34#[UsesClass(TextPattern::class)]
35final class SourceCodeTest extends TestCase
36{
37 public function testExcerptShowsContextAndLinksToTheHighlightedCallInTheFullFile(): void
38 {
39 $entry = new CatalogEntry('a', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('src/a.php', 12, 'f', 'pdo.query'), []);
40 $site = new ReportSite(new Catalog([$entry], [], ['src/a.php' => implode("\n", range(1, 30))]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
41 $html = (new SourceCode())->excerpt($site, $entry);
42
43 self::assertStringStartsWith(
44 '<section><h2 id="source">Source code</h2><p class="muted">src/a.php:12 · '
45 . '<a href="../files/src-a-php.html#L12">View full source</a></p>'
46 . '<pre class="code code-scroll" tabindex="0" aria-label="PHP source code"><code>',
47 $html,
48 );
49 self::assertStringEndsWith('</code></pre></section>', $html);
50 self::assertStringContainsString('class="code-line is-target" id="L12"', $html);
51 self::assertStringContainsString('id="L4"', $html);
52 self::assertStringContainsString('id="L20"', $html);
53 self::assertStringNotContainsString('id="L3"', $html);
54 self::assertStringNotContainsString('id="L21"', $html);
55 }
56
57 #[DataProvider('providerBoundaryLines')]
58 public function testExcerptClipsAtTheStartAndEndOfTheFile(int $line): void
59 {
60 $source = "<?php\n\t\$db->query(\n\t\t\$sql\n\t);";
61 $entry = new CatalogEntry('a', StatementKind::Unknown, TextPattern::fromText(''), [], [], new CallSite('a.php', $line, 'f', 'pdo.query'), []);
62 $html = (new SourceCode())->excerpt(new ReportSite(new Catalog([$entry], [], ['a.php' => $source]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry);
63 self::assertStringContainsString('id="L1"', $html);
64 self::assertStringContainsString('id="L4"', $html);
65 self::assertStringNotContainsString('id="L0"', $html);
66 self::assertStringNotContainsString('id="L5"', $html);
67 self::assertStringContainsString("\t\$db->query(", $html);
68 }
69
70 /**
71 * @return iterable<string, array{int}>
72 */
73 public static function providerBoundaryLines(): iterable
74 {
75 yield 'first line' => [1];
76 yield 'last line' => [4];
77 }
78
79 public function testExcerptOmitsUnavailableSourceWithoutReadingTheFilesystem(): void
80 {
81 $entry = new CatalogEntry('a', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite(__FILE__, 1, 'f', 'pdo.query'), []);
82 self::assertSame('', (new SourceCode())->excerpt(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry));
83 }
84
85 public function testFileIncludesAllLinesAndMarksEveryCall(): void
86 {
87 $first = new CatalogEntry('a', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 2, 'f', 'pdo.query'), []);
88 $last = new CatalogEntry('b', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 30, 'f', 'pdo.query'), []);
89 $site = new ReportSite(new Catalog([$first, $last], [], ['a.php' => implode("\n", range(1, 30))]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
90 $html = (new SourceCode())->file($site, 'a.php');
91
92 self::assertStringStartsWith(
93 '<section><h2 id="source">Source code</h2><p class="muted">Source captured during analysis. Highlighted lines issue database calls.</p>'
94 . '<pre class="code code-scroll" tabindex="0" aria-label="PHP source code"><code>',
95 $html,
96 );
97 self::assertStringEndsWith('</code></pre></section>', $html);
98 self::assertStringContainsString('id="L1"', $html);
99 self::assertStringContainsString('class="code-line is-target" id="L2"', $html);
100 self::assertStringContainsString('class="code-line is-target" id="L30"', $html);
101 self::assertStringContainsString('href="#L30" aria-label="Line 30"', $html);
102 self::assertSame('', (new SourceCode())->file($site, 'missing.php'));
103 }
104
105 public function testLinesEscapesMarkupQuotesAndInvalidUtf8WithoutDroppingTheSource(): void
106 {
107 $html = (new SourceCode())->lines(['</code></pre><script>alert("x")</script>&\'', "\t\xFF"], 7, [8], 'a"b.html');
108
109 self::assertSame(
110 '<pre class="code code-scroll" tabindex="0" aria-label="PHP source code"><code>'
111 . '<span class="code-line" id="L7"><a class="ln" href="a"b.html#L7" aria-label="Line 7">7</a>'
112 . '</code></pre><script>alert("x")</script>&'</span>' . "\n"
113 . '<span class="code-line is-target" id="L8"><a class="ln" href="a"b.html#L8" aria-label="Line 8">8</a>'
114 . "\t\u{FFFD}" . '</span></code></pre>',
115 $html,
116 );
117 }
118
119 public function testSplitPreservesEmptyLinesAndRecognizesPhpNewlines(): void
120 {
121 self::assertSame(['a', '', 'b', 'c', ''], (new SourceCode())->split("a\r\n\r\nb\rc\n"));
122 self::assertSame([''], (new SourceCode())->split(''));
123 }
124}
125