packages/sql-catalog/src/Reporter/Text/TextReporter.php
1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Reporter\Text;
6
7use Override;
8use SqlCatalog\Core\Catalog\Catalog;
9use SqlCatalog\Core\Catalog\CatalogEntry;
10use SqlCatalog\Core\Reporter\CatalogArtifacts;
11use SqlCatalog\Core\Reporter\ReporterInterface;
12
13/**
14 * Writes the catalog as plain lines, for reading in a terminal.
15 *
16 * @visibility root
17 */
18final class TextReporter implements ReporterInterface
19{
20 /**
21 * The name the artifact is written under.
22 */
23 public const FILE = 'catalog.txt';
24
25 /**
26 * The name the command line selects this reporter by.
27 */
28 #[Override]
29 public function name(): string
30 {
31 return 'text';
32 }
33
34 /**
35 * What the reporter produces.
36 */
37 #[Override]
38 public function description(): string
39 {
40 return 'one block per statement, for reading in a terminal';
41 }
42
43 /**
44 * The catalog rendered as plain text.
45 */
46 #[Override]
47 public function render(Catalog $catalog): CatalogArtifacts
48 {
49 $lines = [];
50 foreach ($catalog->sorted() as $entry) {
51 foreach ($this->entryLines($entry) as $line) {
52 $lines[] = $line;
53 }
54 $lines[] = '';
55 }
56 foreach ($catalog->sorted()->problems() as $problem) {
57 $lines[] = sprintf('! %s: %s', $problem->file, $problem->message);
58 }
59 $lines[] = 'Conditions are not evaluated; runtime reachability is not assessed.';
60 $lines[] = $this->summaryLine($catalog);
61
62 return CatalogArtifacts::one(self::FILE, implode("\n", $lines) . "\n");
63 }
64
65 /**
66 * The lines one statement is written as.
67 *
68 * @return list<string>
69 */
70 public function entryLines(CatalogEntry $entry): array
71 {
72 $lines = [
73 sprintf('%s %s %s', $entry->site->display(), strtoupper($entry->kind->value), $entry->id),
74 sprintf(' in %s via %s', $entry->site->function, $entry->site->sink),
75 ' ' . $entry->sql(),
76 ' ' . $this->statusLine($entry),
77 ];
78 foreach ($entry->placeholders as $placeholder) {
79 $lines[] = sprintf(' %s = %s', $placeholder->token, $placeholder->value?->display() ?? '(unbound)');
80 }
81 foreach ($entry->findings as $finding) {
82 $lines[] = sprintf(' [%s] %s %s', strtoupper($finding->severity->value), $finding->rule->value, $finding->message);
83 }
84
85 return $lines;
86 }
87
88 /**
89 * The line saying how far the analyzer got with a statement.
90 */
91 public function statusLine(CatalogEntry $entry): string
92 {
93 $status = [$entry->resolution()->value];
94 if (!$entry->searchClosed()) {
95 $status[] = 'search did not close';
96 }
97 if (!$entry->correlated) {
98 $status[] = 'alternatives may be unreachable';
99 }
100 if (count($entry->through) > 1) {
101 $status[] = 'via ' . implode(' -> ', $entry->through);
102 }
103
104 return implode('; ', $status);
105 }
106
107 /**
108 * The closing line naming what the run found.
109 */
110 public function summaryLine(Catalog $catalog): string
111 {
112 $exact = 0;
113 $findings = 0;
114 foreach ($catalog as $entry) {
115 $exact += $entry->isExact() ? 1 : 0;
116 $findings += count($entry->findings);
117 }
118
119 return sprintf(
120 '%d statement(s), %d fully resolved, %d finding(s), %d unreadable file(s).',
121 $catalog->count(),
122 $exact,
123 $findings,
124 count($catalog->problems()),
125 );
126 }
127}
128