Skip to content

Commit 4fb0cdc

Browse files
committed
Initial Commit
1 parent ff61c42 commit 4fb0cdc

File tree

6 files changed

+345
-2
lines changed

6 files changed

+345
-2
lines changed

.env

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
WARDEN_ENV_NAME=depends
2+
WARDEN_ENV_TYPE=laravel
3+
WARDEN_WEB_ROOT=/
4+
WARDEN_PHP=1
5+
PHP_VERSION=7.2
6+
TRAEFIK_DOMAIN=depends.test
7+
TRAEFIK_SUBDOMAIN=app

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
vendor/
2+
composer.lock

README.md

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,26 @@
1-
# depends-annotation
2-
A composer-related @depends annotation to improve coding around dependencies
1+
# The @dependency Annotation
2+
3+
This project supplies a Composer plugin that adds a command (`why-block`) that interprets the PHP `@dependency`
4+
annotation.
5+
6+
## How to use the `@dependency` annotation
7+
8+
Simply include a `@dependency` annotation in any slash-based comment block, in the following format:
9+
10+
@dependency composer-package:version-constraint [Explanation]
11+
12+
All fields except the explanation are mandatory. Adding an explanation is _highly recommended_, however.
13+
14+
The version-constraint field cannot contain spaces (even if surrounded by quotes).
15+
16+
## How to process reasons not to upgrade a composer dependency
17+
18+
If you are using the `@dependency` annotation thoroughly, and you are having issues updating a composer dependency, you
19+
can use the command `composer why-block composer-package version`
20+
21+
This will output a list of files containing a `@dependency` annotation on composer-package with a version-constraint
22+
that cannot be fulfilled by your specified version.
23+
24+
## How to install
25+
26+
`composer global require navarr/dependency-annotation`

composer.json

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "navarr/dependency-annotation",
3+
"description": "Adds extra functionality for interpreting the @dependency annotation",
4+
"type": "composer-plugin",
5+
"license": "MIT",
6+
"require": {
7+
"php": "^7.2",
8+
"composer-plugin-api": "^1.0",
9+
"composer/composer": "^1",
10+
"composer/semver": "^1|^2|^3",
11+
"symfony/console": "^5"
12+
}
13+
,
14+
"require-dev": {
15+
"roave/security-advisories": "dev-master",
16+
"phpstan/phpstan": "^0.12.32"
17+
},
18+
"autoload": {
19+
"psr-4": {
20+
"Navarr\\Depends\\": "src/"
21+
}
22+
},
23+
"extra": {
24+
"class": "Navarr\\Depends\\Plugin"
25+
}
26+
}

src/Command/WhyBlockCommand.php

+242
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<?php
2+
/**
3+
* @copyright 2020 Navarr Barnier. All Rights Reserved.
4+
*/
5+
6+
declare(strict_types=1);
7+
8+
namespace Navarr\Depends\Command;
9+
10+
use Composer\Command\BaseCommand;
11+
use Composer\Composer;
12+
use Composer\Package\Link;
13+
use Composer\Package\PackageInterface;
14+
use Composer\Semver\Semver;
15+
use Symfony\Component\Console\Input\InputArgument;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
/**
21+
* @dependency composer/composer:^1 Extends BaseCommand
22+
*/
23+
class WhyBlockCommand extends BaseCommand
24+
{
25+
private const ALL_DEPS = 'include-all-dependencies';
26+
private const ROOT_DEPS = 'include-root-dependencies';
27+
28+
private const INLINE_MATCH_PACKAGE = 2;
29+
private const INLINE_MATCH_VERSION = 3;
30+
private const INLINE_MATCH_REASON = 4;
31+
32+
/**
33+
* @dependency symfony/console:^5 Command's setName, addArgument, addOption methods
34+
* @dependency symfony/console:^5 InputArgument::REQUIRED and InputOption::VALUE_NONE
35+
*/
36+
protected function configure(): void
37+
{
38+
$this->setName('why-block')
39+
->addArgument('package', InputArgument::REQUIRED, 'Package to inspect')
40+
->addArgument('version', InputArgument::REQUIRED, 'Version you want to update it to')
41+
->addOption(
42+
self::ROOT_DEPS,
43+
['r'],
44+
InputOption::VALUE_NONE,
45+
'Whether or not to search root dependencies for the @dependency annotation',
46+
null
47+
)
48+
->addOption(
49+
self::ALL_DEPS,
50+
['a'],
51+
InputOption::VALUE_NONE,
52+
'Whether or not to search all dependencies for the @dependency annotation',
53+
null
54+
);
55+
}
56+
57+
/**
58+
* @dependency symfony/console:^5 InputInterface's getOption method
59+
* @dependency symfony/console:^5 OutputInterface's writeln method
60+
*
61+
* @param InputInterface $input
62+
* @param OutputInterface $output
63+
* @return int Exit code
64+
*/
65+
protected function execute(InputInterface $input, OutputInterface $output): int
66+
{
67+
/** @var Composer $composer required indicates it can never be null. */
68+
$composer = $this->getComposer(true);
69+
70+
// Always check the base files
71+
$results = static::getAllFilesForAutoload('.', $composer->getPackage()->getAutoload());
72+
73+
$packages = [];
74+
// If we're checking dependencies, grab all packages
75+
if ($input->getOption(self::ROOT_DEPS) || $input->getOption(self::ALL_DEPS)) {
76+
$packages = $composer->getRepositoryManager()->getLocalRepository()->getPackages();
77+
}
78+
79+
// If we're only checking root dependencies, determine them and filter down `$packages`
80+
if ($input->getOption(self::ROOT_DEPS)) {
81+
$requires = array_map(
82+
static function (Link $link) {
83+
return $link->getTarget();
84+
},
85+
$composer->getPackage()->getRequires()
86+
);
87+
$packages = array_filter(
88+
$packages,
89+
static function (PackageInterface $package) use ($requires) {
90+
return in_array($package->getName(), $requires, true);
91+
}
92+
);
93+
}
94+
95+
// Find all files for the packages
96+
foreach ($packages as $package) {
97+
$path = 'vendor' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $package->getName());
98+
$results = static::getAllFilesForAutoload($path, $package->getAutoload(), $results);
99+
}
100+
101+
$found = false;
102+
foreach ($results as $file) {
103+
$contents = file_get_contents($file);
104+
if ($contents === false) {
105+
continue;
106+
}
107+
$matches = [];
108+
109+
// Double slash comments
110+
preg_match_all(
111+
'#//\s+@(dependency|composerDependency)\s+([^:\s]+):(\S+)\s(.*)?(?=$)#im',
112+
$contents,
113+
$matches,
114+
PREG_OFFSET_CAPTURE
115+
);
116+
$found = $this->processMatches($matches, $input, $contents, $output, $file);
117+
118+
// Slash asterisk comments. We're cheating here and only using an asterisk as indicator. False
119+
// positives possible.
120+
preg_match_all(
121+
'#\*\s+@(dependency|composerDependency)\s+([^:]+):(\S+) ?(.*)$#im',
122+
$contents,
123+
$matches,
124+
PREG_OFFSET_CAPTURE
125+
);
126+
$found = $this->processMatches($matches, $input, $contents, $output, $file) || $found;
127+
}
128+
129+
if (!$found) {
130+
/** @var string $package */
131+
$package = $input->getArgument('package');
132+
$output->writeln('We found no documented reason for ' . $package . ' being blocked.');
133+
}
134+
135+
return 0;
136+
}
137+
138+
/**
139+
* Process any potential matches after a Regex Search for dependency annotations
140+
*
141+
* @param array<array> $matches Output of {@see preg_match_all} with PREG_OFFSET_CAPTURE flag set
142+
* @param InputInterface $input
143+
* @param string $contents Entire contents of a PHP file
144+
* @param OutputInterface $output
145+
* @param string $file Filename
146+
* @return bool Whether or not any matches were found in the file
147+
*/
148+
protected function processMatches(
149+
array $matches,
150+
InputInterface $input,
151+
string $contents,
152+
OutputInterface $output,
153+
string $file
154+
): bool {
155+
$found = false;
156+
157+
$matchCount = count($matches[0]) ?? 0;
158+
for ($match = 0; $match < $matchCount; ++$match) {
159+
$package = strtolower($matches[static::INLINE_MATCH_PACKAGE][$match][0]);
160+
if ($package !== $input->getArgument('package')) {
161+
continue;
162+
}
163+
164+
/** @var string $version */
165+
$version = $input->getArgument('version');
166+
167+
// @dependency composer/semver:^1|^2|^3 We need the Semver::satisfies static method
168+
if (Semver::satisfies($version, $matches[static::INLINE_MATCH_VERSION][$match][0])) {
169+
continue;
170+
}
171+
172+
$found = true;
173+
174+
$pos = $matches[0][$match][1];
175+
$line = substr_count(mb_substr($contents, 0, $pos), "\n") + 1;
176+
177+
$reason = trim($matches[static::INLINE_MATCH_REASON][$match][0]) ?? 'No reason provided';
178+
if (substr($reason, -2) === '*/') {
179+
$reason = trim(substr($reason, 0, -2));
180+
}
181+
182+
$output->writeln(
183+
$file . ':' . $line . ' ' .
184+
$reason . ' ' .
185+
'(' . $matches[static::INLINE_MATCH_VERSION][$match][0] . ')'
186+
);
187+
}
188+
return $found;
189+
}
190+
191+
/**
192+
* Retrieve all PHP files out of the directories and files listed in the autoload directive
193+
*
194+
* @param string $basePath Base directory of the package who's autoload we're processing
195+
* @param array<array> $autoload Result of {@see PackageInterface::getAutoload()}
196+
* @param string[] $results Array of file paths to merge with
197+
* @return string[] File paths
198+
*/
199+
private static function getAllFilesForAutoload(string $basePath, array $autoload, array $results = []): array
200+
{
201+
foreach ($autoload as $map) {
202+
foreach ($map as $dir) {
203+
$realDir = realpath($basePath . DIRECTORY_SEPARATOR . $dir);
204+
if ($realDir === false) {
205+
continue;
206+
}
207+
$results = static::getAllPhpFiles($realDir, $results);
208+
}
209+
}
210+
return $results;
211+
}
212+
213+
/**
214+
* Find all PHP files by recursively searching a directory
215+
*
216+
* @param string $dir Directory to search recursively
217+
* @param string[] $results Array of file paths to merge with
218+
* @return string[] File paths
219+
*/
220+
private static function getAllPhpFiles(string $dir, array $results = []): array
221+
{
222+
$files = scandir($dir);
223+
if ($files === false) {
224+
return $results;
225+
}
226+
227+
foreach ($files as $key => $value) {
228+
$path = realpath($dir . DIRECTORY_SEPARATOR . $value);
229+
if ($path === false) {
230+
continue;
231+
}
232+
233+
if (!is_dir($path) && substr($path, -4) === '.php') {
234+
$results[] = $path;
235+
} elseif (!in_array($value, ['.', '..'])) {
236+
$results = static::getAllPhpFiles($path, $results);
237+
}
238+
}
239+
240+
return $results;
241+
}
242+
}

src/Plugin.php

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
/**
3+
* @copyright 2020 Navarr Barnier. All Rights Reserved.
4+
*/
5+
6+
declare(strict_types=1);
7+
8+
namespace Navarr\Depends;
9+
10+
use Composer\Composer;
11+
use Composer\IO\IOInterface;
12+
use Composer\Plugin\Capability\CommandProvider;
13+
use Composer\Plugin\Capable;
14+
use Composer\Plugin\PluginInterface;
15+
use Navarr\Depends\Command\WhyBlockCommand;
16+
17+
/**
18+
* In charge of registering everything our plugin needs
19+
*
20+
* @dependency composer-plugin-api:^1 Reliant Interfaces
21+
* @dependency composer/composer:^1 Existence of IOInterface and Composer class
22+
*/
23+
class Plugin implements PluginInterface, Capable, CommandProvider
24+
{
25+
public function activate(Composer $composer, IOInterface $io): void
26+
{
27+
}
28+
29+
public function getCapabilities(): array
30+
{
31+
return [
32+
CommandProvider::class => static::class,
33+
];
34+
}
35+
36+
public function getCommands(): array
37+
{
38+
return [
39+
new WhyBlockCommand(),
40+
];
41+
}
42+
}

0 commit comments

Comments
 (0)