Skip to content

Commit 5d4ee02

Browse files
committed
refactor composer json handling away from composer json utility classes
1 parent bada76f commit 5d4ee02

3 files changed

Lines changed: 153 additions & 38 deletions

File tree

src/Composer/Json/ComposerJson.php

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,16 @@
2020
namespace FastForward\DevTools\Composer\Json;
2121

2222
use RuntimeException;
23-
use UnexpectedValueException;
24-
use Composer\Factory;
2523
use Composer\InstalledVersions;
26-
use Composer\Json\JsonFile;
2724
use DateTimeImmutable;
2825
use FastForward\DevTools\Composer\Json\Schema\Author;
2926
use FastForward\DevTools\Composer\Json\Schema\AuthorInterface;
3027
use FastForward\DevTools\Composer\Json\Schema\Funding;
3128
use FastForward\DevTools\Composer\Json\Schema\Support;
3229
use FastForward\DevTools\Composer\Json\Schema\SupportInterface;
30+
use FastForward\DevTools\Path\WorkingProjectPathResolver;
3331
use UnderflowException;
34-
35-
use function Safe\realpath;
32+
use function Safe\json_decode;
3633

3734
/**
3835
* Represents a specialized reader for a Composer JSON file.
@@ -83,17 +80,17 @@ final class ComposerJson implements ComposerJsonInterface
8380
* default Composer file path SHALL be used.
8481
*
8582
* @throws RuntimeException when $path is'nt provided and COMPOSER environment variable is set to a directory
86-
* @throws UnexpectedValueException when composer.json can't be parsed
83+
* @throws RuntimeException when composer manifest files cannot be read or parsed
8784
*/
8885
public function __construct(?string $path = null)
8986
{
90-
$pathLocal = realpath(Factory::getComposerFile());
87+
$pathLocal = WorkingProjectPathResolver::getProjectPath('composer.json');
9188

9289
$path ??= $pathLocal;
9390
$installedJsonPath = \dirname($pathLocal) . '/vendor/composer/installed.json';
9491

95-
$this->data = (new JsonFile($path))->read();
96-
$this->installed = (new JsonFile($installedJsonPath))->read();
92+
$this->data = $this->readComposerJsonFile($path);
93+
$this->installed = $this->readComposerInstalledManifest($installedJsonPath);
9794
}
9895

9996
/**
@@ -206,7 +203,7 @@ public function getReadme(): string
206203
*/
207204
public function getTime(): ?DateTimeImmutable
208205
{
209-
$packages = $this->installed['packages'];
206+
$packages = $this->installed['packages'] ?? [];
210207

211208
if (isset($packages[$this->getName()])) {
212209
return new DateTimeImmutable($packages[$this->getName()]['time']);
@@ -584,4 +581,60 @@ public function getComments(): array
584581

585582
return \is_array($comments) ? $comments : [];
586583
}
584+
585+
/**
586+
* Reads and decodes a composer manifest file.
587+
*
588+
* @param string $path the manifest path
589+
*
590+
* @return array<string, mixed> the parsed payload
591+
*/
592+
private function readComposerJsonFile(string $path): array
593+
{
594+
if (! file_exists($path)) {
595+
throw new RuntimeException(
596+
\sprintf('Unable to read composer manifest file at path: %s', $path),
597+
);
598+
}
599+
600+
return $this->decodeJson($path);
601+
}
602+
603+
/**
604+
* Reads and decodes the composer installed manifest.
605+
*
606+
* @param string $path installed manifest path
607+
*
608+
* @return array<string, mixed> the parsed payload
609+
*/
610+
private function readComposerInstalledManifest(string $path): array
611+
{
612+
if (! file_exists($path)) {
613+
return [];
614+
}
615+
616+
return $this->decodeJson($path);
617+
}
618+
619+
/**
620+
* Decodes a JSON file.
621+
*
622+
* @param string $path the file path
623+
*
624+
* @return array<string, mixed> the decoded payload
625+
*/
626+
private function decodeJson(string $path): array
627+
{
628+
$contents = file_get_contents($path);
629+
630+
if (false === $contents) {
631+
throw new RuntimeException(
632+
\sprintf('Unable to read composer manifest file at path: %s', $path),
633+
);
634+
}
635+
636+
$data = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
637+
638+
return \is_array($data) ? $data : [];
639+
}
587640
}

src/Console/Command/UpdateComposerJsonCommand.php

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@
2020
namespace FastForward\DevTools\Console\Command;
2121

2222
use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23-
use Composer\Command\BaseCommand;
24-
use Composer\Factory;
25-
use Composer\Json\JsonManipulator;
2623
use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
2724
use FastForward\DevTools\Console\Input\HasJsonOption;
2825
use FastForward\DevTools\Filesystem\FilesystemInterface;
@@ -32,11 +29,16 @@
3229
use Psr\Log\LogLevel;
3330
use Symfony\Component\Config\FileLocatorInterface;
3431
use Symfony\Component\Console\Attribute\AsCommand;
32+
use Symfony\Component\Console\Command\Command;
3533
use Symfony\Component\Console\Input\InputInterface;
3634
use Symfony\Component\Console\Input\InputOption;
3735
use Symfony\Component\Console\Output\OutputInterface;
36+
use Symfony\Component\Console\Question\ConfirmationQuestion;
37+
use Symfony\Component\Console\Style\SymfonyStyle;
3838
use Symfony\Component\Filesystem\Path;
3939

40+
use function Safe\json_decode;
41+
use function Safe\json_encode;
4042
use function Safe\getcwd;
4143

4244
/**
@@ -46,7 +48,7 @@
4648
name: 'update-composer-json',
4749
description: 'Updates composer.json with Fast Forward dev-tools scripts and metadata.'
4850
)]
49-
final class UpdateComposerJsonCommand extends BaseCommand implements LoggerAwareCommandInterface
51+
final class UpdateComposerJsonCommand extends Command implements LoggerAwareCommandInterface
5052
{
5153
use HasJsonOption;
5254
use LogsCommandResults;
@@ -59,13 +61,15 @@ final class UpdateComposerJsonCommand extends BaseCommand implements LoggerAware
5961
* @param FileLocatorInterface $fileLocator the locator used to resolve packaged configuration files
6062
* @param FileDiffer $fileDiffer
6163
* @param LoggerInterface $logger the output-aware logger
64+
* @param SymfonyStyle $io
6265
*/
6366
public function __construct(
6467
private readonly ComposerJsonInterface $composer,
6568
private readonly FilesystemInterface $filesystem,
6669
private readonly FileLocatorInterface $fileLocator,
6770
private readonly FileDiffer $fileDiffer,
6871
private readonly LoggerInterface $logger,
72+
private readonly SymfonyStyle $io,
6973
) {
7074
parent::__construct();
7175
}
@@ -86,7 +90,7 @@ protected function configure(): void
8690
shortcut: 'f',
8791
mode: InputOption::VALUE_OPTIONAL,
8892
description: 'Path to the composer.json file to update.',
89-
default: Factory::getComposerFile(),
93+
default: 'composer.json',
9094
)
9195
->addOption(
9296
name: 'dry-run',
@@ -132,22 +136,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
132136
}
133137

134138
$currentContents = $this->filesystem->readFile($file);
135-
$manipulator = new JsonManipulator($currentContents);
136-
$grumphpConfig = DevToolsPathResolver::getPackagePath('grumphp.yml');
137-
138-
foreach ($this->scripts() as $name => $command) {
139-
$manipulator->addSubNode('scripts', $name, $command);
140-
}
141-
142-
if ('' === $this->composer->getReadme() && $this->filesystem->exists('README.md', \dirname($file))) {
143-
$manipulator->addProperty('readme', 'README.md');
144-
}
145-
146-
$manipulator->addSubNode('extra', 'grumphp', [
147-
'config-default-path' => Path::makeRelative($grumphpConfig, getcwd()),
148-
], true);
149-
150-
$updatedContents = $manipulator->getContents();
139+
$updatedContents = $this->updatedComposerJsonContents($currentContents, $file);
151140
$comparison = $this->fileDiffer->diffContents(
152141
'generated dev-tools composer.json configuration',
153142
$file,
@@ -205,8 +194,68 @@ protected function execute(InputInterface $input, OutputInterface $output): int
205194
*/
206195
private function shouldUpdateComposerJson(string $file): bool
207196
{
208-
return $this->getIO()
209-
->askConfirmation(\sprintf('Update managed file %s? [y/N] ', $file), false);
197+
$confirmationMessage = \sprintf(
198+
'composer.json file %s has changes. Do you want to update it with the new dev-tools configuration?',
199+
$file,
200+
);
201+
202+
$confirmation = new ConfirmationQuestion($confirmationMessage, false);
203+
204+
return $this->io->askQuestion($confirmation);
205+
}
206+
207+
/**
208+
* Builds the managed composer.json payload.
209+
*
210+
* @param string $currentContents the current composer.json file contents
211+
* @param string $file the path being updated, used to resolve local README checks
212+
*
213+
* @return string the composer.json payload with managed sections applied
214+
*/
215+
private function updatedComposerJsonContents(string $currentContents, string $file): string
216+
{
217+
$composerJsonData = json_decode($currentContents, true, 512, \JSON_THROW_ON_ERROR);
218+
219+
if (! \is_array($composerJsonData)) {
220+
$composerJsonData = [];
221+
}
222+
223+
$scripts = $composerJsonData['scripts'] ?? [];
224+
if (! \is_array($scripts)) {
225+
$scripts = [];
226+
}
227+
228+
foreach ($this->scripts() as $name => $command) {
229+
$scripts[$name] = $command;
230+
}
231+
232+
$composerJsonData['scripts'] = $scripts;
233+
234+
if ('' === $this->composer->getReadme() && $this->filesystem->exists('README.md', \dirname($file))) {
235+
if (! isset($composerJsonData['readme'])) {
236+
$composerJsonData['readme'] = 'README.md';
237+
}
238+
}
239+
240+
$extra = $composerJsonData['extra'] ?? [];
241+
if (! \is_array($extra)) {
242+
$extra = [];
243+
}
244+
245+
$grumphpConfig = DevToolsPathResolver::getPackagePath('grumphp.yml');
246+
$grumphpExtra = $extra['grumphp'] ?? [];
247+
if (! \is_array($grumphpExtra)) {
248+
$grumphpExtra = [];
249+
}
250+
251+
$grumphpExtra['config-default-path'] = Path::makeRelative($grumphpConfig, getcwd());
252+
$extra['grumphp'] = $grumphpExtra;
253+
$composerJsonData['extra'] = $extra;
254+
255+
return json_encode(
256+
$composerJsonData,
257+
\JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
258+
) . "\n";
210259
}
211260

212261
/**

src/Funding/ComposerFundingCodec.php

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,11 @@
1919

2020
namespace FastForward\DevTools\Funding;
2121

22-
use Composer\Json\JsonFile;
23-
24-
use function Safe\json_encode;
2522
use function Safe\parse_url;
2623
use function Safe\preg_match;
2724
use function array_values;
25+
use function Safe\json_decode;
26+
use function Safe\json_encode;
2827
use function trim;
2928

3029
/**
@@ -41,7 +40,7 @@
4140
*/
4241
public function parse(string $contents): FundingProfile
4342
{
44-
$data = JsonFile::parseJson($contents);
43+
$data = $this->decodeJsonContents($contents);
4544
$funding = $data['funding'] ?? [];
4645

4746
if (! \is_array($funding)) {
@@ -120,7 +119,7 @@ public function dump(string $contents, FundingProfile $profile): string
120119
$entries[] = $unsupportedEntry;
121120
}
122121

123-
$data = JsonFile::parseJson($contents);
122+
$data = $this->decodeJsonContents($contents);
124123
unset($data['funding']);
125124

126125
if ([] === $entries) {
@@ -188,4 +187,18 @@ private function insertFundingEntries(array $data, array $entries): array
188187

189188
return $orderedData;
190189
}
190+
191+
/**
192+
* Decodes a Composer JSON payload to an array.
193+
*
194+
* @param string $contents the JSON source
195+
*
196+
* @return array<string, mixed> the decoded payload
197+
*/
198+
private function decodeJsonContents(string $contents): array
199+
{
200+
$data = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
201+
202+
return \is_array($data) ? $data : [];
203+
}
191204
}

0 commit comments

Comments
 (0)