Lines
90.90%
50 / 55
Methods
66.66%
6 / 9
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| planChanges | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 3 | ||
| writeChanges | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 7 | ||
| outcomeFor | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 5 | ||
| refuseLargeShrink | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| orphanedGeneratedFiles | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 9 | ||
| ensureDirectory | 75.00% | 3 / 4 | 0.00% | 0 / 1 | 4.25 | ||
| writeFileAtomically | 40.00% | 2 / 5 | 0.00% | 0 / 1 | 7.46 | ||
| removeFile | 50.00% | 1 / 2 | 0.00% | 0 / 1 | 4.12 | ||
| 12 | final readonly class OutputDirectory | |
| 13 | { | |
| 14 | private const string PHP_EXTENSION = '.php'; | |
| 15 | ||
| 16 | public function __construct(private string $path) {} | |
| 17 | ||
| 18 | /** | |
| 19 | * @throws CodegenException | |
| 20 | * @param list<GeneratedFile> $files | |
| 21 | */ | |
| 22 | public function planChanges(array $files): FileChangeCollection | |
| 23 | { | |
| 24 | $changes = []; | |
| 25 | $rendered = []; | |
| 26 | ||
| 27 | foreach ($files as $file) { | |
| 28 | $rendered[$file->path] = true; | |
| 29 | $changes[] = new FileChange($file->path, $this->outcomeFor($file)); | |
| 30 | } | |
| 31 | ||
| 32 | foreach ($this->orphanedGeneratedFiles($rendered) as $path) { | |
| 33 | $changes[] = new FileChange($path, FileOutcome::Deleted); | |
| 34 | } | |
| 35 | ||
| 36 | return FileChangeCollection::fromChangesSortedByPath($changes); | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * @throws CodegenException | |
| 41 | * @param list<GeneratedFile> $files | |
| 42 | */ | |
| 43 | public function writeChanges(array $files, ShrinkPolicy $shrinkPolicy): FileChangeCollection | |
| 44 | { | |
| 45 | $changes = $this->planChanges($files); | |
| 46 | ||
| 47 | if ($shrinkPolicy === ShrinkPolicy::Refuse) { | |
| 48 | $this->refuseLargeShrink($changes); | |
| 49 | } | |
| 50 | ||
| 51 | $written = []; | |
| 52 | ||
| 53 | foreach ($changes->filterStale() as $change) { | |
| 54 | $written[$change->path] = true; | |
| 55 | } | |
| 56 | ||
| 57 | $this->ensureDirectory(); | |
| 58 | ||
| 59 | foreach ($files as $file) { | |
| 60 | if (isset($written[$file->path])) { | |
| 61 | $this->writeFileAtomically($this->path . '/' . $file->path, $file->contents); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | foreach ($changes as $change) { | |
| 66 | if ($change->outcome === FileOutcome::Deleted) { | |
| 67 | $this->removeFile($this->path . '/' . $change->path); | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | return $changes; | |
| 72 | } | |
| 73 | ||
| 74 | /** @throws CodegenException */ | |
| 75 | private function outcomeFor(GeneratedFile $file): FileOutcome | |
| 76 | { | |
| 77 | $target = $this->path . '/' . $file->path; | |
| 78 | ||
| 79 | if (!is_file($target)) { | |
| 80 | return FileOutcome::Created; | |
| 81 | } | |
| 82 | ||
| 83 | $current = file_get_contents($target); | |
| 84 | ||
| 85 | if ($current !== false && !GeneratedCodePrinter::isGenerated($current)) { | |
| 86 | throw CodegenException::createForReason(CodegenError::UnmarkedFileInTheWay, path: $target); | |
| 87 | } | |
| 88 | ||
| 89 | return $current === $file->contents ? FileOutcome::Unchanged : FileOutcome::Updated; | |
| 90 | } | |
| 91 | ||
| 92 | /** @throws CodegenException */ | |
| 93 | private function refuseLargeShrink(FileChangeCollection $changes): void | |
| 94 | { | |
| 95 | $deleted = $changes->countWithOutcome(FileOutcome::Deleted); | |
| 96 | $existing = $changes->countExistingFiles(); | |
| 97 | ||
| 98 | if ($deleted * 2 > $existing) { | |
| 99 | throw CodegenException::createForReason(CodegenError::ShrinkRefused, deleted: $deleted, existing: $existing); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * @param array<string, true> $rendered | |
| 105 | * @return list<string> | |
| 106 | */ | |
| 107 | private function orphanedGeneratedFiles(array $rendered): array | |
| 108 | { | |
| 109 | if (!is_dir($this->path)) { | |
| 110 | return []; | |
| 111 | } | |
| 112 | ||
| 113 | $orphaned = []; | |
| 114 | ||
| 115 | foreach (scandir($this->path) ?: [] as $name) { | |
| 116 | $file = $this->path . '/' . $name; | |
| 117 | ||
| 118 | if (!str_ends_with($name, self::PHP_EXTENSION) || isset($rendered[$name]) || !is_file($file)) { | |
| 119 | continue; | |
| 120 | } | |
| 121 | ||
| 122 | $contents = file_get_contents($file); | |
| 123 | ||
| 124 | if ($contents !== false && GeneratedCodePrinter::isGenerated($contents)) { | |
| 125 | $orphaned[] = $name; | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | return $orphaned; | |
| 130 | } | |
| 131 | ||
| 132 | private function ensureDirectory(): void | |
| 133 | { | |
| 134 | if (is_dir($this->path)) { | |
| 135 | return; | |
| 136 | } | |
| 137 | ||
| 138 | if (!mkdir($this->path, 0o777, true) && !is_dir($this->path)) { | |
| 139 | throw CodegenException::createForReason(CodegenError::OutputDirectoryNotCreatable, path: $this->path); | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | private function writeFileAtomically(string $target, string $contents): void | |
| 144 | { | |
| 145 | // Not `.php`, so the stale scan and the autoloader ignore leftovers of an interrupted run. | |
| 146 | $temporary = \sprintf('%s.%s.tmp', $target, bin2hex(random_bytes(4))); | |
| 147 | ||
| 148 | if (file_put_contents($temporary, $contents) === false || !rename($temporary, $target)) { | |
| 149 | if (is_file($temporary)) { | |
| 150 | unlink($temporary); | |
| 151 | } | |
| 152 | ||
| 153 | throw CodegenException::fileNotWritable($target); | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | private function removeFile(string $target): void | |
| 158 | { | |
| 159 | if (is_file($target) && !unlink($target)) { | |
| 160 | throw CodegenException::createForReason(CodegenError::FileNotDeletable, path: $target); | |
| 161 | } | |
| 162 | } | |
| 163 | } |