diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0ab4d..f630dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes are documented here. The format is based on [Keep a Changelog][keep_a_changelog] and this project adheres to [Semantic Versioning][semantic_versioning]. +## [0.2.0](https://github.com/phalcon/quill/releases/tag/v0.2.0) (2026-xx-xx) + +### Changed + +- `Contracts\Formatter::format()` takes a `Selection` in place of the filter string +- `GenerateCommand::execute()` takes a `Selection` +- Pruning is skipped for any narrowed run, not only a filtered one + +### Added + +- `--namespace=` on `generate`: one namespace and everything beneath it, root implied +- `Selection`, carrying what a run narrows to +- `NamespaceNotFound`, thrown when a requested namespace matches nothing + ## [0.1.0](https://github.com/phalcon/quill/releases/tag/v0.1.0) (2026-08-01) ### Changed diff --git a/README.md b/README.md index 78af9fc..41de567 100644 --- a/README.md +++ b/README.md @@ -17,24 +17,20 @@ API documentation generator for Zephir and PHP sources. -Quill reads a source tree into a typed model, then renders that model. Readers know one -language and nothing about output; formatters know one output format and nothing about the -language it came from. +Quill reads a source tree into a typed model, then renders that model. Readers know one language and nothing about output; formatters know one output format and nothing about the language it came from. ``` -ZephirReader (phalcon/zephir) ─┐ ┌─→ MarkdownFormatter (mkdocs pages) - ├─→ Model ─→ toArray() ─┤ -PhpReader (nikic/php-parser) ─┘ (object graph) └─→ JsonFormatter (model document) +ZephirReader (phalcon/zephir) ─┐ ┌─> MarkdownFormatter (mkdocs pages) + ├> Model -> toArray() ┤ +PhpReader (nikic/php-parser) ─┘ (object graph) └─> JsonFormatter (model document) ``` ## Requirements - PHP `^8.1` -- `phalcon/zephir` to read `.zep` sources; selecting `language: zephir` without it fails with - an explanation rather than a "class not found" +- `phalcon/zephir` to read `.zep` sources; selecting `language: zephir` without it fails with an explanation rather than a "class not found" -`nikic/php-parser` is a hard dependency and comes with quill - the PHP reader is not optional -the way the Zephir one is. +`nikic/php-parser` is a hard dependency and comes with quill - the PHP reader is not optional the way the Zephir one is. ## Install @@ -45,6 +41,7 @@ the way the Zephir one is. vendor/bin/quill generate every page, using ./quill.php vendor/bin/quill generate encryption only pages matching the filter vendor/bin/quill generate --format=json one model document instead + vendor/bin/quill generate --namespace=Phalcon\Config one namespace and below vendor/bin/quill parity left.json right.json structural differences vendor/bin/quill docblocks left.json right.json out.csv @@ -55,16 +52,15 @@ the way the Zephir one is. | `--config=` | explicit path to `quill.php`, default `./quill.php` | | `--output=` | destination override for one run; assets follow the documents | | `--format=` | `markdown` (default) or `json` | +| `--namespace=` | limit to one namespace and everything beneath it; the configured root is implied, so `Config` and `Phalcon\Config` are the same. A namespace matching nothing is an error | | `--help`, `-h` | usage | | `` | positional; narrows what is written, matched case-insensitively | -The registry is always built from every source file regardless of the filter, so cross-page -links stay correct. +The registry is always built from every source file regardless of the filter, so cross-page links stay correct. ## Configuration -Everything project-specific lives in a `quill.php` at the project root. Nothing about any -particular repository is compiled into quill. +Everything project-specific lives in a `quill.php` at the project root. Nothing about any particular repository is compiled into quill. ```php 'zephir', 'source' => 'phalcon', - 'output' => 'nikos/docs/api', - 'assets' => 'nikos/docs/assets/css', + 'output' => 'output/docs/api', + 'assets' => 'output/docs/assets/css', 'repository' => 'phalcon/cphalcon', 'branch' => '5.0.x', 'prefix' => 'phalcon', @@ -92,13 +88,9 @@ return [ | `extension` | file extension the reader collects | | `namespace` | root namespace; headings drop it and page names carry it lowercased | -`source`, `output` and `assets` are relative to `quill.php` unless they start with a slash. -Every key except `assets` is required and must be a non-empty string; anything missing raises -`MissingConfigurationKey` naming the key. +`source`, `output` and `assets` are relative to `quill.php` unless they start with a slash. Every key except `assets` is required and must be a non-empty string; anything missing raises `MissingConfigurationKey` naming the key. -Splitting `output` from `assets` lets the destination mirror the layout of whatever consumes -it. With the values above, `cp -r nikos/docs/* /docs/` lands the pages and the -stylesheet where each belongs. +Splitting `output` from `assets` lets the destination mirror the layout of whatever consumes it. With the values above, `cp -r nikos/docs/* /docs/` lands the pages and the stylesheet where each belongs. ## What `generate` writes @@ -106,10 +98,7 @@ stylesheet where each belongs. - an `index` page linking to the rest - the formatter's static assets, if it has any -A complete run also prunes: documents in the output directory that this run did not produce -are deleted, so a source namespace that disappears takes its page with it. A filtered run is -deliberately partial and never prunes. Pruning is scoped to the formatter's own extension - -anything else in the directory belongs to someone else. +A complete run also prunes: documents in the output directory that this run did not produce are deleted, so a source namespace that disappears takes its page with it. A filtered run is deliberately partial and never prunes. Pruning is scoped to the formatter's own extension - anything else in the directory belongs to someone else. ## Formatters @@ -121,33 +110,19 @@ anything else in the directory belongs to someone else. | Enums | rendered as classes | `structure.keyword: enum` | | Traits | `Trait` badge, plus a `Used by` list | `structure.keyword: trait` | -The model is deliberately complete and the Markdown formatter is deliberately opinionated: -anything a reader can observe cheaply goes into the model even when today's formatters ignore -it, so adding a formatter never means revisiting a reader. +The model is deliberately complete and the Markdown formatter is deliberately opinionated: anything a reader can observe cheaply goes into the model even when today's formatters ignore it, so adding a formatter never means revisiting a reader. -`api.css` carries selectors only. Colors come from `--api-*` custom properties it reads but -does not define, which leaves the palette - and light and dark - to the site rendering the -pages. +`api.css` carries selectors only. Colors come from `--api-*` custom properties it reads but does not define, which leaves the palette - and light and dark - to the site rendering the pages. ## The model as an integration point -`ClassDefinition::toArray()` serializes a whole definition and carries a `version`. It is a -published format the moment anything reads it, so treat a shape change as a version bump. A -document written by one installation is read back by another, and `parity` refuses a document -whose version it does not recognize rather than reporting the moved keys as differences. +`ClassDefinition::toArray()` serializes a whole definition and carries a `version`. It is a published format the moment anything reads it, so treat a shape change as a version bump. A document written by one installation is read back by another, and `parity` refuses a document whose version it does not recognize rather than reporting the moved keys as differences. -A `ClassDefinition` is six things: a `Location` (fqcn, namespace, relPath), a `Structure` -(keyword plus modifiers, `null` rather than `false` where they do not apply), a `description`, -`Imports`, `Relations` and `Members`. The serialization mirrors that graph exactly. +A `ClassDefinition` is six things: a `Location` (fqcn, namespace, relPath), a `Structure` (keyword plus modifiers, `null` rather than `false` where they do not apply), a `description`, `Imports`, `Relations` and `Members`. The serialization mirrors that graph exactly. -`uses` and `traits` are different relations that share a keyword: `uses` are the file's -namespace imports, `traits` are what the class body pulls in. `Registry` inverts the latter -into `usedBy()`. +`uses` and `traits` are different relations that share a keyword: `uses` are the file's namespace imports, `traits` are what the class body pulls in. `Registry` inverts the latter into `usedBy()`. -Names in `Relations` are absolute and backslash-prefixed. Both languages spell a parent three -ways - `\Foo`, `Foo` behind a `use`, or `Foo` meaning the sibling in the same namespace - and -the readers resolve all three as they read, so two trees that agree cannot look like they -disagree. +Names in `Relations` are absolute and backslash-prefixed. Both languages spell a parent three ways - `\Foo`, `Foo` behind a `use`, or `Foo` meaning the sibling in the same namespace - and the readers resolve all three as they read, so two trees that agree cannot look like they disagree. ## Comparing two implementations @@ -156,12 +131,13 @@ disagree. vendor/bin/quill generate --format=json --config=cphalcon/quill.php vendor/bin/quill generate --format=json --config=phalcon/quill.php -`parity` reports definitions present on one side only and, for shared ones, which members -differ. It exits non-zero when anything differs, so it can gate a build. +Add `--namespace=` to both sides to compare one subsystem at a time, which keeps the diff readable: -`docblocks` writes the documentation disagreements to a spreadsheet, one row per difference -with a `winner` column to fill in. Rows where one side is blank are pre-filled; the rest are -a human decision. Nothing here edits source. + vendor/bin/quill generate --format=json --namespace=Phalcon\Config --config=cphalcon/quill.php + +`parity` reports definitions present on one side only and, for shared ones, which members differ. It exits non-zero when anything differs, so it can gate a build. + +`docblocks` writes the documentation disagreements to a spreadsheet, one row per difference with a `winner` column to fill in. Rows where one side is blank are pre-filled; the rest are a human decision. Nothing here edits source. ## Development @@ -171,8 +147,7 @@ a human decision. Nothing here edits source. docker exec -w /srv quill-8.1 composer analyze docker exec -w /srv quill-8.1 composer cs -`quill-8.1` is the floor and where the byte-for-byte comparison runs; `quill-8.5` covers -deprecations. The suite must pass on both. +`quill-8.1` is the floor and where the byte-for-byte comparison runs; `quill-8.5` covers deprecations. The suite must pass on both. ## License diff --git a/bin/quill b/bin/quill index b080e87..b9156ce 100755 --- a/bin/quill +++ b/bin/quill @@ -20,6 +20,7 @@ use Phalcon\Quill\Config; use Phalcon\Quill\Exceptions\Exception; use Phalcon\Quill\Formatter\FormatterFactory; use Phalcon\Quill\Reader\ReaderFactory; +use Phalcon\Quill\Selection; foreach ([__DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../../autoload.php'] as $file) { if (file_exists($file)) { @@ -50,6 +51,7 @@ $usage = $banner . <<<'USAGE' Usage: quill generate [filter] [--config=] [--output=] [--format=] + [--namespace=] quill parity quill docblocks @@ -62,6 +64,11 @@ $usage = $banner . <<<'USAGE' --format picks the output (markdown or json), defaulting to markdown. The optional filter limits what is written, matched case-insensitively. + --namespace limits the run to one namespace and everything beneath it, + matched exactly. The configured root is implied, so Config and + Phalcon\Config mean the same thing. A namespace matching nothing is an + error rather than an empty document. + USAGE; $argv = $_SERVER['argv'] ?? []; @@ -73,11 +80,24 @@ if ($command === null || in_array($command, ['-h', '--help', 'help'], true)) { exit(0); } -if ($command === 'docblocks') { - $args = array_values(array_filter( +/** + * The tokens after the command name that are not options, in order. Both + * comparison commands take their arguments positionally and neither accepts + * an option, so an option among them is dropped rather than rejected. + * + * @param list $argv + * + * @return list + */ +$positionals = static function (array $argv): array { + return array_values(array_filter( array_slice($argv, 2), - static fn (string $t): bool => !str_starts_with($t, '-') + static fn (string $token): bool => !str_starts_with($token, '-') )); +}; + +if ($command === 'docblocks') { + $args = $positionals($argv); if (count($args) !== 3) { fwrite(STDERR, 'quill: docblocks needs two model documents and a csv path' . PHP_EOL . $usage); @@ -95,10 +115,7 @@ if ($command === 'docblocks') { } if ($command === 'parity') { - $paths = array_values(array_filter( - array_slice($argv, 2), - static fn (string $t): bool => !str_starts_with($t, '-') - )); + $paths = $positionals($argv); if (count($paths) !== 2) { fwrite(STDERR, 'quill: parity needs two model documents' . PHP_EOL . $usage); @@ -125,6 +142,7 @@ $configPath = 'quill.php'; $outputDir = null; $format = 'markdown'; $filter = ''; +$namespace = ''; foreach (array_slice($argv, 2) as $token) { if (str_starts_with($token, '--config=')) { @@ -145,6 +163,12 @@ foreach (array_slice($argv, 2) as $token) { continue; } + if (str_starts_with($token, '--namespace=')) { + $namespace = substr($token, 12); + + continue; + } + if (str_starts_with($token, '-')) { fwrite(STDERR, "quill: unknown option '{$token}'" . PHP_EOL . $usage); @@ -165,7 +189,7 @@ try { (new FormatterFactory())->create($format) ); - exit($command->execute($config, $filter)); + exit($command->execute($config, Selection::of($filter, $namespace, $config))); } catch (Exception $exception) { fwrite(STDERR, 'quill: ' . $exception->getMessage() . PHP_EOL); diff --git a/resources/api.css b/resources/api.css index bdb83a7..0ff3a4f 100644 --- a/resources/api.css +++ b/resources/api.css @@ -1,4 +1,9 @@ /* ============================================================ + Generated by quill - do not edit in a documentation repository. + The original is resources/api.css in phalcon/quill; `quill generate` + writes this file alongside the pages and any local edit is lost on + the next run. + API documentation (api-v2 layout) - structure. These selectors restate the markup quill emits, so they belong diff --git a/src/Cli/GenerateCommand.php b/src/Cli/GenerateCommand.php index 4394056..c85a305 100644 --- a/src/Cli/GenerateCommand.php +++ b/src/Cli/GenerateCommand.php @@ -15,9 +15,13 @@ use Phalcon\Quill\Config; use Phalcon\Quill\Contracts\Formatter; +use Phalcon\Quill\Exceptions\NamespaceNotFound; use Phalcon\Quill\Exceptions\WriteFailed; +use Phalcon\Quill\Model\Registry; use Phalcon\Quill\Reader\ReaderFactory; +use Phalcon\Quill\Selection; +use function array_keys; use function basename; use function file_put_contents; use function fwrite; @@ -47,14 +51,17 @@ public function __construct( } /** - * The registry always covers every source file; `$filter` narrows only + * The registry always covers every source file; `$selection` narrows only * what is written out. */ - public function execute(Config $config, string $filter = ''): int + public function execute(Config $config, Selection $selection): int { $reader = $this->factory->create($config->language()); $registry = $reader->read($config); - $pages = $this->formatter->format($registry, $config, $filter); + + $this->guardNamespace($registry, $selection); + + $pages = $this->formatter->format($registry, $config, $selection); $output = $config->outputDir(); if (!is_dir($output)) { @@ -80,9 +87,9 @@ public function execute(Config $config, string $filter = ''): int fwrite($this->stdout, 'Asset: ' . basename($path) . PHP_EOL); } - // Only a complete run may prune. A filtered run is deliberately partial, - // so what it did not write was never asked about, not stale. - if ($filter === '') { + // Only an unnarrowed run may prune. A narrowed run is deliberately + // partial, so what it did not write was never asked about, not stale. + if (!$selection->narrows()) { foreach ($this->prune($output, $written) as $path) { fwrite($this->stdout, 'Removed: ' . basename($path) . PHP_EOL); } @@ -93,6 +100,26 @@ public function execute(Config $config, string $filter = ''): int return 0; } + /** + * Checked against the registry rather than the formatter's output, because + * a JSON run would otherwise emit a structurally valid document holding no + * definitions - a silent success where a typo belongs. + */ + private function guardNamespace(Registry $registry, Selection $selection): void + { + if ($selection->namespace === '') { + return; + } + + foreach (array_keys($registry->definitions()->all()) as $fqcn) { + if ($selection->matchesNamespace($fqcn)) { + return; + } + } + + throw new NamespaceNotFound($selection->namespace); + } + /** * Deletes documents this run did not produce, so a source namespace that * disappears takes its page with it instead of leaving an orphan that the diff --git a/src/Contracts/Formatter.php b/src/Contracts/Formatter.php index bd60c18..033f254 100644 --- a/src/Contracts/Formatter.php +++ b/src/Contracts/Formatter.php @@ -15,6 +15,7 @@ use Phalcon\Quill\Config; use Phalcon\Quill\Model\Registry; +use Phalcon\Quill\Selection; /** * Turns the model into output. Every presentation decision - what to hide, @@ -39,10 +40,10 @@ public function assets(): array; public function extension(): string; /** - * The registry always covers every source file; `$filter` narrows only + * The registry always covers every source file; `$selection` narrows only * what gets emitted, index included. * * @return array document key => rendered document */ - public function format(Registry $registry, Config $config, string $filter = ''): array; + public function format(Registry $registry, Config $config, Selection $selection): array; } diff --git a/src/Exceptions/NamespaceNotFound.php b/src/Exceptions/NamespaceNotFound.php new file mode 100644 index 0000000..94df640 --- /dev/null +++ b/src/Exceptions/NamespaceNotFound.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill\Exceptions; + +class NamespaceNotFound extends Exception +{ + public function __construct(string $namespace) + { + parent::__construct( + "No definitions were found in '" . $namespace . "'. Nothing was" + . ' written; check the namespace against the source tree.' + ); + } +} diff --git a/src/Formatter/JsonFormatter.php b/src/Formatter/JsonFormatter.php index 1c074ae..665f710 100644 --- a/src/Formatter/JsonFormatter.php +++ b/src/Formatter/JsonFormatter.php @@ -18,6 +18,7 @@ use Phalcon\Quill\Model\ClassDefinition; use Phalcon\Quill\Model\Document; use Phalcon\Quill\Model\Registry; +use Phalcon\Quill\Selection; use function json_encode; use function stripos; @@ -58,14 +59,13 @@ public function extension(): string * * @return array */ - public function format(Registry $registry, Config $config, string $filter = ''): array + public function format(Registry $registry, Config $config, Selection $selection): array { - $definitions = $registry->definitions(); - if ($filter !== '') { - $definitions = $definitions->filter( - static fn (ClassDefinition $class, string $fqcn): bool => stripos($fqcn, $filter) !== false - ); - } + $definitions = $registry->definitions()->filter( + static fn (ClassDefinition $class, string $fqcn): bool + => $selection->matchesNamespace($fqcn) + && ($selection->filter === '' || stripos($fqcn, $selection->filter) !== false) + ); $serialized = []; foreach ($definitions->sorted() as $fqcn => $class) { diff --git a/src/Formatter/Markdown/Classes.php b/src/Formatter/Markdown/Classes.php new file mode 100644 index 0000000..f34bedb --- /dev/null +++ b/src/Formatter/Markdown/Classes.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill\Formatter\Markdown; + +/** + * Every CSS class the Markdown output emits. + * + * These names are an integration contract with whatever renders the pages - + * `resources/api.css` styles them, and a documentation site's own stylesheet + * supplies the `--api-*` palette they read. Scattered as string literals, a + * rename was invisible here and surfaced as an unstyled page on a published + * site; gathered here, the set is something you can read, diff and version. + * + * Changing a value is a breaking change for the stylesheet. Adding one means + * adding a rule to api.css in the same commit. + */ +final class Classes +{ + public const BADGE = 'badge'; + public const BADGE_PREFIX = 'badge--'; + + public const GROUP = 'api-group'; + public const ITEM = 'api-item'; + public const LIST = 'api-list'; + public const TREE = 'api-tree'; + public const USED_BY = 'api-used-by'; + public const USES = 'api-uses'; + + public const SOURCE_BUTTON = 'src-btn'; + + public const DESCRIPTION = 'desc'; + public const PARAMETER = 'prm'; + public const RETURN_TYPE = 'ret'; + public const SIGNATURE = 'sig'; + public const VISIBILITY = 'vis'; + public const VISIBILITY_PREFIX = 'vis-'; + + public const TOKEN_CONSTANT = 'sc'; + public const TOKEN_FUNCTION = 'sf'; + public const TOKEN_MUTED = 'sm'; + public const TOKEN_TYPE = 'st'; + public const TOKEN_VARIABLE = 'sv'; +} diff --git a/src/Formatter/Markdown/Html.php b/src/Formatter/Markdown/Html.php index 3dbd418..d5f9b1e 100644 --- a/src/Formatter/Markdown/Html.php +++ b/src/Formatter/Markdown/Html.php @@ -36,7 +36,7 @@ public function default(?string $default): string return ''; } - return ' = ' . $this->escape($default) . ''; + return ' = ' . $this->escape($default) . ''; } public function escape(string $text): string diff --git a/src/Formatter/Markdown/Naming.php b/src/Formatter/Markdown/Naming.php index 4100270..dc0d1d9 100644 --- a/src/Formatter/Markdown/Naming.php +++ b/src/Formatter/Markdown/Naming.php @@ -13,7 +13,6 @@ namespace Phalcon\Quill\Formatter\Markdown; -use Phalcon\Quill\Config; use Phalcon\Quill\Model\ClassDefinition; use function explode; @@ -38,34 +37,34 @@ final class Naming /** * The mkdocs heading anchor: the title with everything mkdocs would strip. */ - public function anchor(ClassDefinition $class, Config $config): string + public function anchor(ClassDefinition $class, Presentation $presentation): string { - return strtolower((string) preg_replace('/[^\w\s-]/', '', $this->title($class, $config))); + return strtolower((string) preg_replace('/[^\w\s-]/', '', $this->title($class, $presentation))); } - public function methodAnchor(ClassDefinition $class, string $methodName, Config $config): string + public function methodAnchor(ClassDefinition $class, string $methodName, Presentation $presentation): string { - return $this->anchor($class, $config) . '-' . strtolower($methodName); + return $this->anchor($class, $presentation) . '-' . strtolower($methodName); } /** * The page a definition belongs on: its top-level namespace segment. */ - public function pageKey(ClassDefinition $class, Config $config): string + public function pageKey(ClassDefinition $class, Presentation $presentation): string { $segments = explode(DIRECTORY_SEPARATOR, $class->location->relPath); - $key = str_replace('.' . $config->extension(), '', $segments[0]); + $key = str_replace('.' . $presentation->extension, '', $segments[0]); - return $config->pagePrefix() . strtolower($key); + return $presentation->pagePrefix . strtolower($key); } /** * The heading text: the FQCN without the root namespace, which the page's * own notice already states. */ - public function title(ClassDefinition $class, Config $config): string + public function title(ClassDefinition $class, Presentation $presentation): string { - $root = $config->rootNamespace() . '\\'; + $root = $presentation->rootNamespace . '\\'; return str_starts_with($class->location->fqcn, $root) ? substr($class->location->fqcn, strlen($root)) diff --git a/src/Formatter/Markdown/Presentation.php b/src/Formatter/Markdown/Presentation.php new file mode 100644 index 0000000..0ba8ec5 --- /dev/null +++ b/src/Formatter/Markdown/Presentation.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill\Formatter\Markdown; + +use Phalcon\Quill\Config; + +/** + * The four project-specific values the Markdown output needs, and nothing else. + * + * Config carries nine keys describing a source tree, a reader and a repository. + * Rendering a page needs four of them, so it takes these rather than the whole + * object - a formatter has no business knowing which language was read or where + * the sources live, and every key added to Config used to widen a type a dozen + * signatures already carried. + */ +final class Presentation +{ + private function __construct( + public readonly string $extension, + public readonly string $pagePrefix, + public readonly string $rootNamespace, + private readonly Config $config, + ) { + } + + public static function from(Config $config): self + { + return new self( + $config->extension(), + $config->pagePrefix(), + $config->rootNamespace(), + $config + ); + } + + /** + * The "Source on GitHub" link for a definition's file. + * + * Delegated rather than copied: the URL is built from three more Config + * values that nothing else here needs, and duplicating the format would put + * the link's shape in two places. + */ + public function sourceUrl(string $relativePath): string + { + return $this->config->sourceUrl($relativePath); + } +} diff --git a/src/Formatter/Markdown/Signature.php b/src/Formatter/Markdown/Signature.php index 1d512aa..bc5093c 100644 --- a/src/Formatter/Markdown/Signature.php +++ b/src/Formatter/Markdown/Signature.php @@ -40,7 +40,7 @@ public function __construct(private readonly Html $html) */ public function inline(MethodDefinition $method): string { - $name = '' . $this->html->escape($method->name) . ''; + $name = '' . $this->html->escape($method->name) . ''; $params = $this->htmlParams($method->parameters); if (count($method->parameters) < 2) { @@ -53,7 +53,7 @@ public function inline(MethodDefinition $method): string $last = count($params) - 1; foreach ($params as $index => $param) { $comma = $index < $last ? ',' : ''; - $lines .= '' . $param . $comma . ''; + $lines .= '' . $param . $comma . ''; } return $name . '(' . $lines . ')'; @@ -93,8 +93,10 @@ private function htmlParams(ParameterDefinitionCollection $parameters): array { $rendered = []; foreach ($parameters as $parameter) { - $rendered[] = '' . $this->html->escape($parameter->type) . '' - . ' $' . $this->html->escape($parameter->name) . '' + $rendered[] = '' + . $this->html->escape($parameter->type) . '' + . ' $' + . $this->html->escape($parameter->name) . '' . $this->html->default($parameter->default); } diff --git a/src/Formatter/MarkdownFormatter.php b/src/Formatter/MarkdownFormatter.php index cf58ee2..e7e75a6 100644 --- a/src/Formatter/MarkdownFormatter.php +++ b/src/Formatter/MarkdownFormatter.php @@ -16,13 +16,16 @@ use Phalcon\Quill\Config; use Phalcon\Quill\Contracts\Formatter; use Phalcon\Quill\Exceptions\MissingAsset; +use Phalcon\Quill\Formatter\Markdown\Classes; use Phalcon\Quill\Formatter\Markdown\Html; use Phalcon\Quill\Formatter\Markdown\Naming; +use Phalcon\Quill\Formatter\Markdown\Presentation; use Phalcon\Quill\Formatter\Markdown\Signature; use Phalcon\Quill\Model\ClassDefinition; use Phalcon\Quill\Model\Keyword; use Phalcon\Quill\Model\MethodDefinitionCollection; use Phalcon\Quill\Model\Registry; +use Phalcon\Quill\Selection; use function array_keys; use function array_map; @@ -95,13 +98,14 @@ public function extension(): string /** * @return array */ - public function format(Registry $registry, Config $config, string $filter = ''): array + public function format(Registry $registry, Config $config, Selection $selection): array { - $pages = $this->pages($registry, $config); + $view = Presentation::from($config); + $pages = $this->pages($registry, $view, $selection); $output = []; foreach (array_keys($pages) as $page) { - if ($filter !== '' && stripos($page, $filter) === false) { + if ($selection->filter !== '' && stripos($page, $selection->filter) === false) { unset($pages[$page]); } } @@ -119,7 +123,7 @@ public function format(Registry $registry, Config $config, string $filter = ''): EOT; foreach (array_keys($pages) as $page) { - $index .= $this->indexLine($page, $config); + $index .= $this->indexLine($page, $view); } $output['index'] = $index; @@ -133,14 +137,14 @@ public function format(Registry $registry, Config $config, string $filter = ''): !!! info "NOTE" - All classes are prefixed with `{$config->rootNamespace()}` + All classes are prefixed with `{$view->rootNamespace}` EOT; foreach ($fqcns as $fqcn) { $class = $registry->get($fqcn); if ($class !== null) { - $document .= $this->classDoc($class, $registry, $config); + $document .= $this->classDoc($class, $registry, $view); } } @@ -150,7 +154,7 @@ public function format(Registry $registry, Config $config, string $filter = ''): return $output; } - private function classDoc(ClassDefinition $class, Registry $registry, Config $config): string + private function classDoc(ClassDefinition $class, Registry $registry, Presentation $view): string { $badge = 'Class'; $css = 'class'; @@ -169,23 +173,25 @@ private function classDoc(ClassDefinition $class, Registry $registry, Config $co $css = 'final'; } - $output = "\n\n## " . $this->naming->title($class, $config) . "\n\n" - . "{$badge}\n" + $badgeClass = Classes::BADGE . ' ' . Classes::BADGE_PREFIX . $css; + + $output = "\n\n## " . $this->naming->title($class, $view) . "\n\n" + . "{$badge}\n" . "[:material-github: Source on GitHub]" - . '(' . $config->sourceUrl($class->location->relPath) . ')' - . "{ .src-btn }\n"; + . '(' . $view->sourceUrl($class->location->relPath) . ')' + . '{ .' . Classes::SOURCE_BUTTON . " }\n"; if ($class->description !== '') { $output .= "\n" . $class->description . "\n"; } - $output .= $this->tree($class, $registry, $config); + $output .= $this->tree($class, $registry, $view); $output .= $this->uses($class); - $output .= $this->usedBy($class, $registry, $config); - $output .= $this->summary($class, $config); + $output .= $this->usedBy($class, $registry, $view); + $output .= $this->summary($class, $view); $output .= $this->constants($class); $output .= $this->properties($class); - $output .= $this->methodDetails($class, $config); + $output .= $this->methodDetails($class, $view); return $output; } @@ -196,19 +202,15 @@ private function constants(ClassDefinition $class): string return ''; } - $output = "\n### Constants\n\n
\n"; + $output = "\n### Constants\n\n" . $this->openList(); foreach ($class->members->constants as $constant) { - $output .= "
\n" - . '' . $this->html->escape($constant->varType) . "\n" - . '' . $this->html->escape($constant->name) - . '' . $this->html->default($constant->default) . "\n"; - - if ($constant->description !== '') { - $output .= '' - . $this->html->inlineCode($constant->description) . "\n"; - } - - $output .= "
\n"; + $output .= $this->row( + '' . $this->html->escape($constant->varType) . "\n" + . '' . $this->html->escape($constant->name) + . '' . $this->html->default($constant->default) . "\n", + $constant->description + ); } return $output . "
\n"; @@ -221,7 +223,7 @@ private function fqcnLink( string $display, ?string $fqcn, Registry $registry, - Config $config, + Presentation $view, string $currentPage ): string { $target = $fqcn === null ? null : $registry->get($fqcn); @@ -229,8 +231,8 @@ private function fqcnLink( return "`{$display}`"; } - $href = '#' . $this->naming->anchor($target, $config); - $targetPage = $this->naming->pageKey($target, $config); + $href = '#' . $this->naming->anchor($target, $view); + $targetPage = $this->naming->pageKey($target, $view); if ($targetPage !== $currentPage) { $href = $targetPage . '.md' . $href; } @@ -238,18 +240,18 @@ private function fqcnLink( return "[`{$display}`]({$href})"; } - private function indexLine(string $page, Config $config): string + private function indexLine(string $page, Presentation $view): string { - $label = ucfirst(str_replace($config->pagePrefix(), '', $page)); + $label = ucfirst(str_replace($view->pagePrefix, '', $page)); - return '- [' . $config->rootNamespace() . ' ' . $label . ']' + return '- [' . $view->rootNamespace . ' ' . $label . ']' . '(' . $page . '.md)' . PHP_EOL; } - private function methodDetails(ClassDefinition $class, Config $config): string + private function methodDetails(ClassDefinition $class, Presentation $view): string { $groups = $this->orderMethods($class->members->methods); - if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) { + if ($groups === null) { return ''; } @@ -262,10 +264,11 @@ private function methodDetails(ClassDefinition $class, Config $config): string $count = $groups[$group]->count(); $label = ucfirst($group); - $output .= "\n
{$label} · {$count}
\n"; + $groupClass = Classes::GROUP; + $output .= "\n
{$label} · {$count}
\n"; foreach ($groups[$group] as $method) { - $anchor = $this->naming->methodAnchor($class, $method->name, $config); + $anchor = $this->naming->methodAnchor($class, $method->name, $view); $signature = implode("\n", $this->signature->lines($method)); $output .= "\n#### `{$method->name}()` { #{$anchor} }\n\n" @@ -280,24 +283,40 @@ private function methodDetails(ClassDefinition $class, Config $config): string return $output; } + /** + * The opening tag of a member list. Three sections emit one, and the class + * name on it is part of the contract with the stylesheet. + */ + private function openList(): string + { + return '
\n"; + } + /** * Private methods dropped, reserved (__*) first, then alphabetical, split - * by visibility. + * by visibility. Null when nothing survives. * * The emptiness guard is on the result rather than the incoming list: the * model keeps private members, so a class whose methods are all private - * would otherwise emit a heading with nothing under it. + * would otherwise emit a heading with nothing under it. Both sections that + * render methods open with this question, so both ask it here. * - * @return array{public: MethodDefinitionCollection, protected: MethodDefinitionCollection} + * @return array{public: MethodDefinitionCollection, protected: MethodDefinitionCollection}|null */ - private function orderMethods(MethodDefinitionCollection $methods): array + private function orderMethods(MethodDefinitionCollection $methods): ?array { $visible = $methods->withoutPrivate()->ordered(); - return [ + $groups = [ 'public' => $visible->withVisibility('public'), 'protected' => $visible->withVisibility('protected'), ]; + + if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) { + return null; + } + + return $groups; } /** @@ -308,11 +327,15 @@ private function orderMethods(MethodDefinitionCollection $methods): array * * @return array> */ - private function pages(Registry $registry, Config $config): array + private function pages(Registry $registry, Presentation $view, Selection $selection): array { $pages = []; foreach ($registry->definitions() as $fqcn => $class) { - $pages[$this->naming->pageKey($class, $config)][] = $fqcn; + if (!$selection->matchesNamespace($fqcn)) { + continue; + } + + $pages[$this->naming->pageKey($class, $view)][] = $fqcn; } ksort($pages); @@ -332,45 +355,87 @@ private function properties(ClassDefinition $class): string return ''; } - $output = "\n### Properties\n\n
\n"; + $output = "\n### Properties\n\n" . $this->openList(); foreach ($visible as $property) { $visibility = $property->visibility; + $visClass = Classes::VISIBILITY . ' ' . Classes::VISIBILITY_PREFIX . $visibility; + + $output .= $this->row( + "{$visibility}\n" + . '' . $this->html->escape($property->varType) . "\n" + . '$' . $this->html->escape($property->name) + . '' . $this->html->default($property->default) . "\n", + $property->description + ); + } - $output .= "
\n" - . "{$visibility}\n" - . '' . $this->html->escape($property->varType) . "\n" - . '$' . $this->html->escape($property->name) - . '' . $this->html->default($property->default) . "\n"; + return $output . "
\n"; + } - if ($property->description !== '') { - $output .= '' - . $this->html->inlineCode($property->description) . "\n"; - } + /** + * One relation's names as a comma-separated list of links, for the + * `extends`/`implements` annotation under a class in the tree. A name the + * registry does not hold falls back to plain code, which fqcnLink handles. + * + * @param list $names + */ + private function relationLinks( + array $names, + ClassDefinition $class, + Registry $registry, + Presentation $view, + string $currentPage + ): string { + $links = []; + foreach ($names as $name) { + $fqcn = $registry->resolve($name, $class); + $links[] = $this->fqcnLink($fqcn ?? $name, $fqcn, $registry, $view, $currentPage); + } - $output .= "
\n"; + return implode(', ', $links); + } + + /** + * One row of an `api-list`: the wrapper, whatever the caller puts inside, + * and the description when there is one. + * + * Only the envelope is shared - a constant row and a property row differ in + * the middle, and forcing those through one signature would take more + * parameters than it saves lines. + */ + private function row(string $body, string $description): string + { + $output = '
\n" . $body; + + if ($description !== '') { + $output .= '' + . $this->html->inlineCode($description) . "\n"; } return $output . "
\n"; } - private function summary(ClassDefinition $class, Config $config): string + private function summary(ClassDefinition $class, Presentation $view): string { $groups = $this->orderMethods($class->members->methods); - if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) { + if ($groups === null) { return ''; } - $output = "\n### Method Summary\n\n
\n"; + $output = "\n### Method Summary\n\n" . $this->openList(); foreach (['public', 'protected'] as $group) { foreach ($groups[$group] as $method) { - $anchor = $this->naming->methodAnchor($class, $method->name, $config); + $anchor = $this->naming->methodAnchor($class, $method->name, $view); + $itemClass = Classes::ITEM; + $visClass = Classes::VISIBILITY . ' ' . Classes::VISIBILITY_PREFIX . $group; - $output .= "\n" - . "{$group}\n"; + $output .= "\n" + . "{$group}\n"; if ($method->returnType !== null) { - $output .= '' + $output .= '' . $this->html->escape($method->returnType) . "\n"; } @@ -409,15 +474,15 @@ private function summaryLine(string $description): string return ''; } - private function tree(ClassDefinition $class, Registry $registry, Config $config): string + private function tree(ClassDefinition $class, Registry $registry, Presentation $view): string { - $currentPage = $this->naming->pageKey($class, $config); + $currentPage = $this->naming->pageKey($class, $view); $level = 0; $lines = []; foreach ($registry->ancestorsOf($class) as $ancestor) { $lines[] = str_repeat(' ', $level * 4) . '- ' - . $this->fqcnLink($ancestor['display'], $ancestor['fqcn'], $registry, $config, $currentPage); + . $this->fqcnLink($ancestor['display'], $ancestor['fqcn'], $registry, $view, $currentPage); $level++; } @@ -425,23 +490,13 @@ private function tree(ClassDefinition $class, Registry $registry, Config $config $annotations = []; if ($class->structure->keyword === Keyword::Interface && count($class->relations->extends) > 1) { - $links = []; - foreach ($class->relations->extends as $name) { - $fqcn = $registry->resolve($name, $class); - $links[] = $this->fqcnLink($fqcn ?? $name, $fqcn, $registry, $config, $currentPage); - } - - $annotations[] = 'extends ' . implode(', ', $links); + $annotations[] = 'extends ' + . $this->relationLinks($class->relations->extends, $class, $registry, $view, $currentPage); } if ($class->relations->implements !== []) { - $links = []; - foreach ($class->relations->implements as $name) { - $fqcn = $registry->resolve($name, $class); - $links[] = $this->fqcnLink($fqcn ?? $name, $fqcn, $registry, $config, $currentPage); - } - - $annotations[] = 'implements ' . implode(', ', $links); + $annotations[] = 'implements ' + . $this->relationLinks($class->relations->implements, $class, $registry, $view, $currentPage); } if ($annotations !== []) { @@ -455,10 +510,10 @@ private function tree(ClassDefinition $class, Registry $registry, Config $config sort($children); foreach ($children as $child) { $lines[] = str_repeat(' ', $level * 4) . '- ' - . $this->fqcnLink($child, $child, $registry, $config, $currentPage); + . $this->fqcnLink($child, $child, $registry, $view, $currentPage); } - return "\n
\n\n" + return "\n
\n\n" . implode("\n", $lines) . "\n\n
\n"; } @@ -468,7 +523,7 @@ private function tree(ClassDefinition $class, Registry $registry, Config $config * links because, unlike the import list, every target is by construction * in the registry. */ - private function usedBy(ClassDefinition $class, Registry $registry, Config $config): string + private function usedBy(ClassDefinition $class, Registry $registry, Presentation $view): string { $users = $registry->usedBy($class); if ($users === []) { @@ -477,14 +532,14 @@ private function usedBy(ClassDefinition $class, Registry $registry, Config $conf sort($users); - $currentPage = $this->naming->pageKey($class, $config); + $currentPage = $this->naming->pageKey($class, $view); $links = array_map( - fn (string $fqcn): string => $this->fqcnLink($fqcn, $fqcn, $registry, $config, $currentPage), + fn (string $fqcn): string => $this->fqcnLink($fqcn, $fqcn, $registry, $view, $currentPage), $users ); - return "\n__Used by__ " . implode(' · ', $links) . "\n{ .api-used-by }\n"; + return "\n__Used by__ " . implode(' · ', $links) . "\n{ ." . Classes::USED_BY . " }\n"; } private function uses(ClassDefinition $class): string @@ -501,6 +556,6 @@ private function uses(ClassDefinition $class): string $uses ); - return "\n__Uses__ " . implode(' · ', $codes) . "\n{ .api-uses }\n"; + return "\n__Uses__ " . implode(' · ', $codes) . "\n{ ." . Classes::USES . " }\n"; } } diff --git a/src/Reader/Docblock.php b/src/Reader/Docblock.php index 31ab02a..b370ec8 100644 --- a/src/Reader/Docblock.php +++ b/src/Reader/Docblock.php @@ -73,11 +73,15 @@ public function description(): string /** * The declared `@var` type, or null when the comment does not carry one. + * + * Normalized through Notation because this is the one type in the model + * that arrives as prose rather than being built, and it is also the one + * that wins - both readers prefer it over the declared type. */ public function varType(): ?string { if (preg_match('/^@var\s+(.+)$/m', $this->text, $matches) === 1) { - return trim($matches[1]); + return Notation::type($matches[1]); } return null; diff --git a/src/Reader/Notation.php b/src/Reader/Notation.php new file mode 100644 index 0000000..58b8f57 --- /dev/null +++ b/src/Reader/Notation.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill\Reader; + +use function addcslashes; +use function preg_replace; +use function trim; + +/** + * How the readers write down what they observe. + * + * Two readers must produce identical models for equivalent sources, and every + * rule they both apply used to live twice - once per reader, kept in agreement + * by comment rather than by code. They disagreed: a string default containing a + * quote came out `"\""` from Zephir and `"""` from PHP, and parity reported it + * as a difference between the two implementations. + * + * The rules live here so there is one place to change and nowhere to drift. + */ +final class Notation +{ + public const ARRAY_EMPTY = '[]'; + public const ARRAY_FILLED = '[...]'; + + public const NULL = 'null'; + + public const TYPE_ARRAY = 'array'; + public const TYPE_BOOL = 'bool'; + public const TYPE_FLOAT = 'float'; + public const TYPE_INT = 'int'; + public const TYPE_MIXED = 'mixed'; + public const TYPE_STRING = 'string'; + + /** + * A single-quoted character default, which only Zephir declares. + */ + public static function char(string $value): string + { + return "'" . $value . "'"; + } + + public static function classConstant(string $class, string $name): string + { + return $class . '::' . $name; + } + + /** + * A string default whose escapes are already in the source form the model + * carries - which is what Zephir's parser hands back, since it returns the + * characters between the quotes rather than their value. + */ + public static function escapedString(string $escaped): string + { + return '"' . $escaped . '"'; + } + + /** + * Zephir cannot write a union, so a typed parameter defaulting to null is + * how it says `?T`. Both readers render that as `T|null`. `mixed` already + * admits null and is left alone. + */ + public static function nullable(string $type): string + { + return $type === self::TYPE_MIXED ? $type : $type . '|null'; + } + + /** + * A string default given as its value rather than its source text, which is + * what php-parser hands back. The escapes go back on here so both readers + * write the same thing. + */ + public static function string(string $value): string + { + return self::escapedString(addcslashes($value, "\"\\")); + } + + /** + * A type as a docblock declared it, written the way the model carries one. + * + * A docblock is prose: `@var Foo | null` and `@var Foo|null` say the same + * thing, and a reader that passes either through verbatim reports the + * spacing as a difference between the two implementations. Every other type + * in the model is built here, so a declared one is squared up to match. + */ + public static function type(string $declared): string + { + return (string) preg_replace('/\s*([|&])\s*/', '$1', trim($declared)); + } + + /** + * @return 'public'|'protected'|'private' + */ + public static function visibility(bool $isPrivate, bool $isProtected): string + { + if ($isPrivate) { + return 'private'; + } + + return $isProtected ? 'protected' : 'public'; + } +} diff --git a/src/Reader/Php/TypeRenderer.php b/src/Reader/Php/TypeRenderer.php index 15662aa..62d6604 100644 --- a/src/Reader/Php/TypeRenderer.php +++ b/src/Reader/Php/TypeRenderer.php @@ -13,6 +13,7 @@ namespace Phalcon\Quill\Reader\Php; +use Phalcon\Quill\Reader\Notation; use PhpParser\Node; use PhpParser\Node\ComplexType; use PhpParser\Node\Identifier; @@ -41,7 +42,7 @@ public function render(?Node $type): ?string if ($type instanceof NullableType) { $inner = $this->render($type->type); - return $inner === null ? 'null' : $inner . '|null'; + return $inner === null ? Notation::NULL : Notation::nullable($inner); } if ($type instanceof UnionType) { diff --git a/src/Reader/Php/ValueRenderer.php b/src/Reader/Php/ValueRenderer.php index fabe39e..2840e94 100644 --- a/src/Reader/Php/ValueRenderer.php +++ b/src/Reader/Php/ValueRenderer.php @@ -20,15 +20,15 @@ use PhpParser\Node\Expr\UnaryMinus; use PhpParser\Node\Scalar\Float_; use PhpParser\Node\Scalar\Int_; +use Phalcon\Quill\Reader\Notation; use PhpParser\Node\Scalar\String_; /** * Renders a default-value expression to the string the model carries. * - * The output deliberately matches the Zephir reader's: quoted strings, bare - * numbers, `[]` for an empty array and `[...]` for a populated one. Two - * models that render defaults differently would report parity differences - * that are only about formatting. + * What a rendered default looks like belongs to Notation, which the Zephir + * reader writes through as well - two models that render defaults differently + * would report parity differences that are only about formatting. */ final class ValueRenderer { @@ -39,7 +39,9 @@ public function render(?Expr $expr): ?string } if ($expr instanceof String_) { - return '"' . $expr->value . '"'; + // php-parser hands back the value, not the source text, so the + // escapes have to go back on. + return Notation::string($expr->value); } if ($expr instanceof Int_ || $expr instanceof Float_) { @@ -52,14 +54,14 @@ public function render(?Expr $expr): ?string } if ($expr instanceof Array_) { - return $expr->items === [] ? '[]' : '[...]'; + return $expr->items === [] ? Notation::ARRAY_EMPTY : Notation::ARRAY_FILLED; } if ($expr instanceof ClassConstFetch) { - $class = $expr->class instanceof Expr ? '' : $expr->class->toString(); - $name = $expr->name instanceof Expr ? '' : $expr->name->toString(); - - return $class . '::' . $name; + return Notation::classConstant( + $expr->class instanceof Expr ? '' : $expr->class->toString(), + $expr->name instanceof Expr ? '' : $expr->name->toString() + ); } if ($expr instanceof UnaryMinus) { diff --git a/src/Reader/PhpReader.php b/src/Reader/PhpReader.php index 3412a31..ed85a99 100644 --- a/src/Reader/PhpReader.php +++ b/src/Reader/PhpReader.php @@ -471,13 +471,13 @@ private function readTraits(Stmt\ClassLike $node, Imports $imports, string $name private function scalarType(?Node\Expr $expr): string { return match (true) { - $expr instanceof Node\Scalar\String_ => 'string', - $expr instanceof Node\Scalar\Int_ => 'int', - $expr instanceof Node\Scalar\Float_ => 'float', - $expr instanceof Node\Expr\Array_ => 'array', + $expr instanceof Node\Scalar\String_ => Notation::TYPE_STRING, + $expr instanceof Node\Scalar\Int_ => Notation::TYPE_INT, + $expr instanceof Node\Scalar\Float_ => Notation::TYPE_FLOAT, + $expr instanceof Node\Expr\Array_ => Notation::TYPE_ARRAY, $expr instanceof Node\Expr\ConstFetch - && implode('', $expr->name->getParts()) !== 'null' => 'bool', - default => 'mixed', + && implode('', $expr->name->getParts()) !== 'null' => Notation::TYPE_BOOL, + default => Notation::TYPE_MIXED, }; } @@ -495,10 +495,14 @@ private function structure(Stmt\ClassLike $node): Structure return Structure::enum(); } - return Structure::classType( - $node instanceof Stmt\Class_ && $node->isAbstract(), - $node instanceof Stmt\Class_ && $node->isFinal() - ); + // Asked once rather than per modifier: the three declarations above + // have returned, so this is a class - but only Stmt\Class_ declares + // isAbstract() and isFinal(), and the analyzer needs to be told. + if ($node instanceof Stmt\Class_) { + return Structure::classType($node->isAbstract(), $node->isFinal()); + } + + return Structure::classType(false, false); } /** @@ -506,10 +510,6 @@ private function structure(Stmt\ClassLike $node): Structure */ private function visibility(bool $isPrivate, bool $isProtected): string { - if ($isPrivate) { - return 'private'; - } - - return $isProtected ? 'protected' : 'public'; + return Notation::visibility($isPrivate, $isProtected); } } diff --git a/src/Reader/ZephirReader.php b/src/Reader/ZephirReader.php index 2d573c1..ec8ec23 100644 --- a/src/Reader/ZephirReader.php +++ b/src/Reader/ZephirReader.php @@ -80,20 +80,16 @@ public function read(Config $config): Registry */ private function methodVisibility(array $modifiers): string { - if (in_array('private', $modifiers, true)) { - return 'private'; - } - - return in_array('protected', $modifiers, true) ? 'protected' : 'public'; + return Notation::visibility( + in_array('private', $modifiers, true), + in_array('protected', $modifiers, true) + ); } /** * Zephir has no union syntax for parameters - anything genuinely of two * types is declared `var`. A null default is the one exception it can - * express, and it means exactly what `?string` means in PHP, so it is - * rendered the same way and the two models line up. - * - * `mixed` already admits null, so it is left alone. + * express, and Notation renders it the way PHP writes `?string`. */ private function parameterType(AstNode $parameter): string { @@ -101,12 +97,11 @@ private function parameterType(AstNode $parameter): string if ($cast !== null) { $type = $cast; } else { - $declared = $parameter->text('data-type') ?? 'variable'; - $type = $declared === 'variable' ? 'mixed' : $declared; + $type = $this->zephirType($parameter->text('data-type') ?? 'var'); } - if ($type !== 'mixed' && $parameter->node('default')?->text('type') === 'null') { - $type .= '|null'; + if ($parameter->node('default')?->text('type') === 'null') { + $type = Notation::nullable($type); } return $type; @@ -119,12 +114,12 @@ private function parameterType(AstNode $parameter): string */ private function propertyVisibility(array $modifiers): string { - if (in_array('private', $modifiers, true)) { - return 'private'; - } - - // A Zephir property declared without an explicit keyword is protected. - return in_array('public', $modifiers, true) ? 'public' : 'protected'; + // A Zephir property declared without an explicit keyword is protected, + // so the absence of `public` is what makes it so. + return Notation::visibility( + in_array('private', $modifiers, true), + !in_array('public', $modifiers, true) + ); } /** @@ -386,7 +381,7 @@ private function readProperties(AstNode $definition): PropertyDefinitionCollecti $this->propertyVisibility($property->strings('visibility')), false, $this->renderDefault($default), - $this->varType($doc, $default), + $this->varType($doc, $default, $property->text('data-type')), $doc->description(), $shortcuts ); @@ -433,17 +428,20 @@ private function renderDefault(?AstNode $expr): ?string $type = $expr->text('type') ?? ''; return match ($type) { - 'string' => '"' . $expr->stringValue('value') . '"', - 'char' => "'" . $expr->stringValue('value') . "'", + 'string' => Notation::escapedString($expr->stringValue('value')), + 'char' => Notation::char($expr->stringValue('value')), 'int', 'uint', 'long', 'double', 'bool' => $expr->stringValue('value'), - 'null' => 'null', - 'empty-array' => '[]', - 'array' => '[...]', - 'static-constant-access' => ($expr->node('left')?->text('value') ?? 'self') - . '::' . ($expr->node('right')?->text('value') ?? ''), + 'null' => Notation::NULL, + 'empty-array' => Notation::ARRAY_EMPTY, + 'array' => Notation::ARRAY_FILLED, + 'static-constant-access' => Notation::classConstant( + $expr->node('left')?->text('value') ?? 'self', + $expr->node('right')?->text('value') ?? '' + ), 'constant' => $expr->stringValue('value'), - 'minus' => '-' . ($this->renderDefault($expr->node('left')) ?? ''), + // No `minus` arm: the parser folds a sign into the literal, so + // `-1` arrives as an int whose value is already "-1". default => $expr->has('value') ? $expr->stringValue('value') : $type, }; } @@ -467,30 +465,69 @@ private function renderReturnType(?AstNode $returnType): ?string continue; } - $dataType = $entry->text('data-type') ?? 'mixed'; - $types[] = $dataType === 'variable' ? 'mixed' : $dataType; + $types[] = $this->zephirType($entry->text('data-type') ?? 'var'); } return $types === [] ? null : implode('|', $types); } /** - * The declared `@var` type, falling back to the default value's type. + * A Zephir type keyword in the model's vocabulary. + * + * Zephir spells several types differently from PHP and the two models have + * to agree: `double` is PHP's `float` - and is what the parser reports even + * when the source says `float` - `char` is a `string`, the sized integers + * are all `int`, and `var` is `mixed`. Anything else is a class name and + * passes through untouched. */ - private function varType(Docblock $doc, ?AstNode $default): string + private function zephirType(string $declared): string { - $declared = $doc->varType(); - if ($declared !== null) { - return $declared; + return match ($declared) { + 'var', 'variable' => Notation::TYPE_MIXED, + 'int', 'uint', 'long', 'ulong' => Notation::TYPE_INT, + 'double', 'float' => Notation::TYPE_FLOAT, + 'char', 'string' => Notation::TYPE_STRING, + 'bool', 'boolean' => Notation::TYPE_BOOL, + 'array' => Notation::TYPE_ARRAY, + default => $declared, + }; + } + + /** + * A member's type, in the order the two languages agree on: the docblock, + * then the declaration, then whatever the default value implies. + * + * The docblock wins because it is the only one of the three that can be + * richer than the declaration - `array` says more than + * `array`, and dropping it for the bare keyword would lose information the + * PHP twin keeps. + * + * The declaration comes before the default because the default is a guess: + * `float ratio = 0` is a float whose default happens to look like an int. + * Zephir only started carrying property types recently, so a source that + * has migrated to them and dropped its docblocks reaches this branch. + * + * `$declared` is null for a constant, which has no declared type, and for + * an untyped property, where the parser omits the key entirely. + */ + private function varType(Docblock $doc, ?AstNode $default, ?string $declared = null): string + { + $documented = $doc->varType(); + if ($documented !== null) { + return $documented; + } + + if ($declared !== null && $declared !== '') { + return $this->zephirType($declared); } return match ($default?->text('type') ?? '') { - 'string', 'char' => 'string', - 'int', 'uint', 'long' => 'int', - 'double' => 'float', - 'bool' => 'bool', - 'empty-array', 'array' => 'array', - default => 'mixed', + 'string', 'char' => Notation::TYPE_STRING, + 'int', 'uint', 'long' => Notation::TYPE_INT, + 'double' => Notation::TYPE_FLOAT, + 'bool' => Notation::TYPE_BOOL, + 'empty-array', 'array' => Notation::TYPE_ARRAY, + default => Notation::TYPE_MIXED, }; } } diff --git a/src/Selection.php b/src/Selection.php new file mode 100644 index 0000000..e1070cf --- /dev/null +++ b/src/Selection.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill; + +use function str_starts_with; +use function trim; + +/** + * What one run narrows its output to. Config is per-project; this is per-run. + * + * The namespace rule lives here because it means one thing in every formatter. + * The filter is carried rather than interpreted - Markdown matches it against a + * page key and JSON against an FQCN, and this type does not arbitrate that. + */ +final class Selection +{ + public readonly string $namespace; + + public function __construct( + public readonly string $filter = '', + string $namespace = '', + ) { + $this->namespace = trim($namespace, '\\'); + } + + /** + * A namespace written without the configured root gets it prepended, so a + * caller need not know whether the root is already there. + */ + public static function of(string $filter, string $namespace, Config $config): self + { + $namespace = trim($namespace, '\\'); + $root = $config->rootNamespace(); + + if ( + $namespace !== '' + && $root !== '' + && $namespace !== $root + && !str_starts_with($namespace, $root . '\\') + ) { + $namespace = $root . '\\' . $namespace; + } + + return new self($filter, $namespace); + } + + public static function none(): self + { + return new self(); + } + + /** + * The separator is part of the test: without it every sibling whose name + * merely starts the same way would match. + */ + public function matchesNamespace(string $fqcn): bool + { + if ($this->namespace === '') { + return true; + } + + return $fqcn === $this->namespace + || str_starts_with($fqcn, $this->namespace . '\\'); + } + + public function narrows(): bool + { + return $this->filter !== '' || $this->namespace !== ''; + } +} diff --git a/tests/Fixtures/twin/php/Subject.php b/tests/Fixtures/twin/php/Subject.php new file mode 100644 index 0000000..c096947 --- /dev/null +++ b/tests/Fixtures/twin/php/Subject.php @@ -0,0 +1,54 @@ + + */ + protected array $store = []; + + // Typed with no docblock, which is where the sources are heading. Zephir + // reports `float` as `double`, so this also pins the keyword mapping. + protected string $plain; + + protected float $ratio; + + protected bool $enabled; + + public function describe(?string $text = null): string + { + // Bodies are not part of the model, so the twins are free to differ here. + return $text ?? ''; + } + + protected function hidden(int $count): void + { + } + + // The parser reports `float` as `double` for both the parameter and the + // return, so this pins the keyword mapping on all three member kinds. + public function scale(float $factor): float + { + return $factor; + } +} diff --git a/tests/Fixtures/twin/zep/Subject.zep b/tests/Fixtures/twin/zep/Subject.zep new file mode 100644 index 0000000..a15ac32 --- /dev/null +++ b/tests/Fixtures/twin/zep/Subject.zep @@ -0,0 +1,49 @@ +namespace Phalcon\Twin; + +/** + * One declaration written twice, once per language. + * + * Its twin in ../php says the same thing in PHP. Both readers must produce the + * same model for it - see ReaderEquivalenceTest. Everything here is a rule the + * two readers have disagreed about at some point: escaped strings, spaced + * unions in a docblock, a null default standing in for a nullable type, and + * the visibility a keyword implies. + */ +class Subject +{ + const QUOTE = "\""; + + /** + * @var string | null + */ + protected label = null; + + /** + * @var array + */ + protected store = []; + + // Typed with no docblock, which is where the sources are heading. Zephir + // reports `float` as `double`, so this also pins the keyword mapping. + protected string plain; + + protected float ratio; + + protected bool enabled; + + public function describe(string text = null) -> string + { + return text; + } + + protected function hidden(int count) -> void + { + } + + // The parser reports `float` as `double` for both the parameter and the + // return, so this pins the keyword mapping on all three member kinds. + public function scale(float factor) -> float + { + return factor; + } +} diff --git a/tests/Fixtures/zep/Shapes.zep b/tests/Fixtures/zep/Shapes.zep index c92e533..e9a69c6 100644 --- a/tests/Fixtures/zep/Shapes.zep +++ b/tests/Fixtures/zep/Shapes.zep @@ -32,6 +32,10 @@ abstract class Shapes extends Consumer implements Countable, Stringable private items = [1, 2]; + // Typed with neither a docblock nor a default, so the declaration is the + // only thing that can name the type. + protected array registry; + public function withCast( item) -> { return item; diff --git a/tests/Unit/Cli/GenerateCommandTest.php b/tests/Unit/Cli/GenerateCommandTest.php index bcaa04c..219603e 100644 --- a/tests/Unit/Cli/GenerateCommandTest.php +++ b/tests/Unit/Cli/GenerateCommandTest.php @@ -15,9 +15,13 @@ use Phalcon\Quill\Cli\GenerateCommand; use Phalcon\Quill\Config; +use Phalcon\Quill\Contracts\Formatter; +use Phalcon\Quill\Exceptions\NamespaceNotFound; use Phalcon\Quill\Exceptions\WriteFailed; +use Phalcon\Quill\Formatter\JsonFormatter; use Phalcon\Quill\Formatter\MarkdownFormatter; use Phalcon\Quill\Reader\ReaderFactory; +use Phalcon\Quill\Selection; use PHPUnit\Framework\TestCase; use function chmod; @@ -54,22 +58,93 @@ protected function tearDown(): void parent::tearDown(); } + /** + * A typo would otherwise produce a structurally valid document holding no + * definitions, which reads as a successful run. + */ + public function testANamespaceMatchingNothingFailsBeforeAnythingIsWritten(): void + { + $this->expectException(NamespaceNotFound::class); + $this->expectExceptionMessageMatches('/Phalcon\\\\Nope/'); + + try { + $this->command()->execute($this->config(), new Selection('', 'Phalcon\\Nope')); + } finally { + $this->assertSame([], glob($this->outputDir . '/*.md') ?: []); + } + } + + public function testANamespacedRunPrunesNothing(): void + { + $stale = $this->outputDir . '/phalcon_stale.md'; + $this->command()->execute($this->config(), Selection::none()); + file_put_contents($stale, 'stale'); + + $this->command()->execute($this->config(), new Selection('', 'Phalcon\\Sample')); + + $this->assertFileExists($stale); + } + + /** + * A formatter with no assets skips the whole step rather than creating an + * empty directory for nothing. + */ + public function testAFormatterWithoutAssetsWritesNone(): void + { + $assets = $this->outputDir . '/assets'; + + $this->command(new JsonFormatter())->execute($this->config($assets), Selection::none()); + + $this->assertDirectoryDoesNotExist($assets); + $this->assertFileExists($this->outputDir . '/model.json'); + } + + /** + * The assets directory is created when it is somewhere other than the one + * the documents go in, which is the layout a documentation site wants. + */ + public function testAnAbsentAssetsDirectoryIsCreated(): void + { + $assets = $this->outputDir . '/nested/assets/css'; + + $this->command()->execute($this->config($assets), Selection::none()); + + $this->assertFileExists($assets . '/' . MarkdownFormatter::STYLESHEET); + } + + public function testAnUnwritableAssetFailsLoudly(): void + { + $assets = $this->outputDir . '/assets'; + mkdir($assets, 0777, true); + + $stylesheet = $assets . '/' . MarkdownFormatter::STYLESHEET; + file_put_contents($stylesheet, ''); + chmod($stylesheet, 0444); + + try { + $this->expectException(WriteFailed::class); + $this->command()->execute($this->config($assets), Selection::none()); + } finally { + chmod($stylesheet, 0644); + } + } + public function testAFilteredRunPrunesNothing(): void { - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); $kept = $this->outputDir . '/phalcon_sample.md'; $this->assertFileExists($kept); // Filtered runs are deliberately partial, so untouched pages stay. - $this->command()->execute($this->config(), 'nothingmatches'); + $this->command()->execute($this->config(), new Selection('nothingmatches')); $this->assertFileExists($kept); } public function testAnUnwritableDestinationFailsLoudly(): void { - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); // Simulate what a root-owned output directory does to a non-root run. $page = $this->outputDir . '/phalcon_sample.md'; @@ -79,7 +154,7 @@ public function testAnUnwritableDestinationFailsLoudly(): void $this->expectExceptionMessage('Could not write'); try { - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); } finally { chmod($page, 0644); } @@ -89,14 +164,14 @@ public function testCreatesTheOutputDirectoryWhenAbsent(): void { $this->assertFalse(file_exists($this->outputDir)); - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); $this->assertDirectoryExists($this->outputDir); } public function testFilterRestrictsWhichPagesAreWritten(): void { - $this->assertSame(0, $this->command()->execute($this->config(), 'nothingmatches')); + $this->assertSame(0, $this->command()->execute($this->config(), new Selection('nothingmatches'))); // The index is always written; no page files survive the filter. $this->assertFileExists($this->outputDir . '/index.md'); @@ -105,12 +180,12 @@ public function testFilterRestrictsWhichPagesAreWritten(): void public function testOtherFileTypesAreLeftAlone(): void { - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); $foreign = $this->outputDir . '/notes.txt'; file_put_contents($foreign, 'not ours'); - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); $this->assertFileExists($foreign); unlink($foreign); @@ -118,13 +193,13 @@ public function testOtherFileTypesAreLeftAlone(): void public function testStaleDocumentsArePruned(): void { - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); // A page whose source namespace has since been deleted. $orphan = $this->outputDir . '/phalcon_gone.md'; file_put_contents($orphan, 'stale'); - $this->command()->execute($this->config()); + $this->command()->execute($this->config(), Selection::none()); $this->assertFileDoesNotExist($orphan); $this->assertFileExists($this->outputDir . '/phalcon_sample.md'); @@ -132,7 +207,7 @@ public function testStaleDocumentsArePruned(): void public function testWritesTheIndexAndEveryPage(): void { - $this->assertSame(0, $this->command()->execute($this->config())); + $this->assertSame(0, $this->command()->execute($this->config(), Selection::none())); $this->assertFileExists($this->outputDir . '/index.md'); $this->assertFileExists($this->outputDir . '/phalcon_sample.md'); @@ -145,26 +220,22 @@ public function testWritesTheIndexAndEveryPage(): void private function clean(): void { - foreach (glob($this->outputDir . '/*') ?: [] as $file) { - if (is_file($file)) { - unlink($file); - } - } - - if (is_dir($this->outputDir)) { - rmdir($this->outputDir); - } + $this->remove($this->outputDir); } - private function command(): GenerateCommand + private function command(?Formatter $formatter = null): GenerateCommand { $stdout = fopen('php://memory', 'rb+'); $this->assertIsResource($stdout); - return new GenerateCommand(new ReaderFactory(), new MarkdownFormatter(), $stdout); + return new GenerateCommand( + new ReaderFactory(), + $formatter ?? new MarkdownFormatter(), + $stdout + ); } - private function config(): Config + private function config(string $assetsDir = ''): Config { return new Config( 'zephir', @@ -174,7 +245,29 @@ private function config(): Config '5.0.x', 'phalcon', 'zep', - 'Phalcon' + 'Phalcon', + $assetsDir ); } + + /** + * Depth first, because a run can write into nested directories now that the + * assets destination need not be the one the documents go in. + */ + private function remove(string $path): void + { + if (!is_dir($path)) { + if (is_file($path)) { + unlink($path); + } + + return; + } + + foreach (glob($path . '/*') ?: [] as $child) { + $this->remove($child); + } + + rmdir($path); + } } diff --git a/tests/Unit/Cli/ParityCommandTest.php b/tests/Unit/Cli/ParityCommandTest.php index ccbe652..02650da 100644 --- a/tests/Unit/Cli/ParityCommandTest.php +++ b/tests/Unit/Cli/ParityCommandTest.php @@ -145,6 +145,34 @@ public function testMembersAreCountedPerSectionAsMissingAndExtra(): void $this->assertStringContainsString('methods -1 +1', $output); } + /** + * Exactly at the limit nothing was held back, so the remainder line has to + * stay silent - the boundary is `<=`, not `<`, and "and 0 more" would be + * both wrong and noise. + */ + public function testACountEqualToTheLimitReportsNoRemainder(): void + { + $left = []; + for ($i = 1; $i <= 3; $i++) { + $left['Left' . $i] = $this->definition([]); + } + + $this->write('left.json', $left); + $this->write('right.json', []); + + $stdout = fopen('php://memory', 'rb+'); + $this->assertIsResource($stdout); + + (new ParityCommand($stdout))->execute($this->dir . '/left.json', $this->dir . '/right.json', 3); + + rewind($stdout); + $output = (string) stream_get_contents($stdout); + + $this->assertStringContainsString('Only on the left: 3', $output); + $this->assertStringContainsString(' Left3', $output); + $this->assertStringNotContainsString('more', $output); + } + /** * Twenty-five is the default the command promises when no limit is given. */ diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 24f56cd..ea36742 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -23,6 +23,39 @@ final class ConfigTest extends TestCase { + /** + * The namespace is a name, not a path, so the separators a project might + * write around it are stripped rather than carried into every page key and + * every resolution candidate. + */ + public function testANamespaceIsTrimmedOfItsSeparators(): void + { + $config = Config::fromArray($this->values(['namespace' => '\\Phalcon\\']), '/project'); + + $this->assertSame('Phalcon', $config->rootNamespace()); + $this->assertSame('phalcon_', $config->pagePrefix()); + } + + /** + * Absent and present-but-empty mean the same thing - assets go where the + * documents go - and neither may be mistaken for a configured path. + */ + public function testAnEmptyOrAbsentAssetsKeyFallsBackToTheOutputDirectory(): void + { + $absent = Config::fromArray($this->values(), '/project'); + $empty = Config::fromArray($this->values(['assets' => '']), '/project'); + + $this->assertSame($absent->outputDir(), $absent->assetsDir()); + $this->assertSame($empty->outputDir(), $empty->assetsDir()); + } + + public function testAConfiguredAssetsDirectoryIsResolvedAgainstTheRoot(): void + { + $config = Config::fromArray($this->values(['assets' => 'docs/assets/css']), '/project'); + + $this->assertSame('/project/docs/assets/css', $config->assetsDir()); + } + public function testAbsolutePathsInConfigPassThroughUnchanged(): void { $config = Config::fromArray( @@ -190,4 +223,27 @@ private function config(): Config 'Phalcon' ); } + + /** + * A complete key set, with any of it overridden per test. + * + * @param array $overrides + * + * @return array + */ + private function values(array $overrides = []): array + { + // The overrides go first: `+` keeps the left operand for a duplicate + // key, so putting the defaults there would silently ignore them. + return $overrides + [ + 'language' => 'zephir', + 'source' => 'phalcon', + 'output' => 'nikos/api', + 'repository' => 'phalcon/cphalcon', + 'branch' => '5.0.x', + 'prefix' => 'phalcon', + 'extension' => 'zep', + 'namespace' => 'Phalcon', + ]; + } } diff --git a/tests/Unit/ExceptionsTest.php b/tests/Unit/ExceptionsTest.php index e04e28b..550bbc4 100644 --- a/tests/Unit/ExceptionsTest.php +++ b/tests/Unit/ExceptionsTest.php @@ -17,6 +17,7 @@ use Phalcon\Quill\Exceptions\IncompatibleDocument; use Phalcon\Quill\Exceptions\MalformedConfiguration; use Phalcon\Quill\Exceptions\MalformedDocument; +use Phalcon\Quill\Exceptions\MissingAsset; use Phalcon\Quill\Exceptions\MissingConfiguration; use Phalcon\Quill\Exceptions\MissingConfigurationKey; use Phalcon\Quill\Exceptions\MissingDependency; @@ -54,6 +55,22 @@ public function testAMalformedDocumentNamesTheFile(): void ); } + /** + * A packaged asset that cannot be found means a broken installation, not a + * misconfigured project, and the message has to say which. + */ + public function testAMissingAssetNamesThePathAndBlamesTheInstall(): void + { + $exception = new MissingAsset('/vendor/phalcon/quill/resources/api.css'); + + $this->assertSame( + "The asset '/vendor/phalcon/quill/resources/api.css' is missing from" + . ' this installation. It ships with quill, so a copy that cannot' + . ' find it is incomplete rather than misconfigured.', + $exception->getMessage() + ); + } + public function testAMissingConfigurationKeyNamesTheKey(): void { $exception = new MissingConfigurationKey('branch'); diff --git a/tests/Unit/Formatter/JsonFormatterTest.php b/tests/Unit/Formatter/JsonFormatterTest.php index 3b8bc6f..72335a1 100644 --- a/tests/Unit/Formatter/JsonFormatterTest.php +++ b/tests/Unit/Formatter/JsonFormatterTest.php @@ -29,6 +29,7 @@ use Phalcon\Quill\Model\Registry; use Phalcon\Quill\Model\Relations; use Phalcon\Quill\Model\Structure; +use Phalcon\Quill\Selection; use PHPUnit\Framework\TestCase; use function array_keys; @@ -36,6 +37,65 @@ final class JsonFormatterTest extends TestCase { + public function testANamespaceAndAFilterCompose(): void + { + $registry = new Registry(ClassDefinitionCollection::fromDefinitions([ + $this->definition('Phalcon\\Sample\\Zulu'), + $this->definition('Phalcon\\Sample\\Deep\\Charlie'), + $this->definition('Phalcon\\Other\\Charlie'), + ])); + + $documents = (new JsonFormatter())->format( + $registry, + $this->config(), + new Selection('charlie', 'Phalcon\\Sample') + ); + + /** @var array $decoded */ + $decoded = json_decode($documents[JsonFormatter::DOCUMENT], true); + + /** @var array $definitions */ + $definitions = $decoded['definitions']; + + // The namespace drops Other\Charlie, the filter drops Sample\Zulu. + $this->assertSame(['Phalcon\\Sample\\Deep\\Charlie'], array_keys($definitions)); + } + + public function testANamespaceNarrowsToItsSubtree(): void + { + $registry = new Registry(ClassDefinitionCollection::fromDefinitions([ + $this->definition('Phalcon\\Sample\\Zulu'), + $this->definition('Phalcon\\Sample\\Deep\\Charlie'), + $this->definition('Phalcon\\Other\\Bravo'), + ])); + + $documents = (new JsonFormatter())->format( + $registry, + $this->config(), + new Selection('', 'Phalcon\\Sample') + ); + + /** @var array $decoded */ + $decoded = json_decode($documents[JsonFormatter::DOCUMENT], true); + + /** @var array $definitions */ + $definitions = $decoded['definitions']; + + $this->assertSame( + ['Phalcon\\Sample\\Deep\\Charlie', 'Phalcon\\Sample\\Zulu'], + array_keys($definitions) + ); + } + + /** + * A model document is data, so there is nothing to style and nothing to + * write beside it. + */ + public function testAModelDocumentShipsNoAssets(): void + { + $this->assertSame([], (new JsonFormatter())->assets()); + } + public function testDefinitionsAreKeyedByFqcnAndSorted(): void { /** @var array $definitions */ @@ -74,7 +134,7 @@ public function testFactoryResolvesBothFormats(): void public function testFilterIsCaseInsensitive(): void { - $pages = (new JsonFormatter())->format($this->registry(), $this->config(), 'ZuLu'); + $pages = (new JsonFormatter())->format($this->registry(), $this->config(), new Selection('ZuLu')); /** @var array $decoded */ $decoded = json_decode($pages[JsonFormatter::DOCUMENT], true); @@ -92,7 +152,7 @@ public function testFilterKeepsLookingPastANonMatch(): void { // The registry hands Zulu over first, so Alpha is only reachable if // the loop carries on past the miss. - $pages = (new JsonFormatter())->format($this->registry(), $this->config(), 'alpha'); + $pages = (new JsonFormatter())->format($this->registry(), $this->config(), new Selection('alpha')); /** @var array $decoded */ $decoded = json_decode($pages[JsonFormatter::DOCUMENT], true); @@ -104,7 +164,7 @@ public function testFilterKeepsLookingPastANonMatch(): void public function testFilterNarrowsByFqcn(): void { - $pages = (new JsonFormatter())->format($this->registry(), $this->config(), 'zulu'); + $pages = (new JsonFormatter())->format($this->registry(), $this->config(), new Selection('zulu')); /** @var array $decoded */ $decoded = json_decode($pages[JsonFormatter::DOCUMENT], true); @@ -121,7 +181,8 @@ public function testFilterNarrowsByFqcn(): void */ public function testTheDocumentIsPrettyPrintedWithUnescapedSlashes(): void { - $json = (new JsonFormatter())->format($this->registry(), $this->config())[JsonFormatter::DOCUMENT]; + $documents = (new JsonFormatter())->format($this->registry(), $this->config(), Selection::none()); + $json = $documents[JsonFormatter::DOCUMENT]; $this->assertStringContainsString("{\n \"version\"", $json); $this->assertStringContainsString('"phalcon/cphalcon"', $json); @@ -132,7 +193,7 @@ public function testTheDocumentIsPrettyPrintedWithUnescapedSlashes(): void public function testWritesOneDocumentWithAJsonExtension(): void { $formatter = new JsonFormatter(); - $pages = $formatter->format($this->registry(), $this->config()); + $pages = $formatter->format($this->registry(), $this->config(), Selection::none()); $this->assertSame('json', $formatter->extension()); $this->assertSame([JsonFormatter::DOCUMENT], array_keys($pages)); @@ -157,7 +218,7 @@ private function config(): Config */ private function decode(): array { - $pages = (new JsonFormatter())->format($this->registry(), $this->config()); + $pages = (new JsonFormatter())->format($this->registry(), $this->config(), Selection::none()); /** @var array $decoded */ $decoded = json_decode($pages[JsonFormatter::DOCUMENT], true); diff --git a/tests/Unit/Formatter/MarkdownFormatterMethodsTest.php b/tests/Unit/Formatter/MarkdownFormatterMethodsTest.php index 9e03c96..180fc01 100644 --- a/tests/Unit/Formatter/MarkdownFormatterMethodsTest.php +++ b/tests/Unit/Formatter/MarkdownFormatterMethodsTest.php @@ -29,6 +29,7 @@ use Phalcon\Quill\Model\Registry; use Phalcon\Quill\Model\Relations; use Phalcon\Quill\Model\Structure; +use Phalcon\Quill\Selection; use PHPUnit\Framework\TestCase; use function strpos; @@ -116,7 +117,7 @@ private function config(): Config private function format(Registry $registry): string { - $pages = (new MarkdownFormatter())->format($registry, $this->config()); + $pages = (new MarkdownFormatter())->format($registry, $this->config(), Selection::none()); return $pages['phalcon_sample'] ?? self::fail('phalcon_sample page missing'); } @@ -126,7 +127,7 @@ private function format(Registry $registry): string */ private function formatted(): array { - return (new MarkdownFormatter())->format($this->registry(), $this->config()); + return (new MarkdownFormatter())->format($this->registry(), $this->config(), Selection::none()); } /** diff --git a/tests/Unit/Formatter/MarkdownFormatterTest.php b/tests/Unit/Formatter/MarkdownFormatterTest.php index 26c1496..621def0 100644 --- a/tests/Unit/Formatter/MarkdownFormatterTest.php +++ b/tests/Unit/Formatter/MarkdownFormatterTest.php @@ -28,12 +28,33 @@ use Phalcon\Quill\Model\Registry; use Phalcon\Quill\Model\Relations; use Phalcon\Quill\Model\Structure; +use Phalcon\Quill\Selection; use PHPUnit\Framework\TestCase; use function substr_count; final class MarkdownFormatterTest extends TestCase { + /** + * A namespace deeper than a page yields a partial page rather than no page: + * grouping is by top-level segment and does not change. + */ + public function testADeepNamespaceLeavesAPartialPage(): void + { + $documents = (new MarkdownFormatter())->format( + $this->nestedRegistry(), + $this->config(), + new Selection('', 'Phalcon\\Sample\\Deep') + ); + + $page = $documents['phalcon_sample'] ?? self::fail('phalcon_sample page missing'); + + // Headings drop the root namespace, so the title reads Sample\Deep\Leaf. + $this->assertStringContainsString('## Sample\\Deep\\Leaf', $page); + $this->assertStringNotContainsString('## Sample\\Base', $page); + $this->assertStringNotContainsString('## Sample\\Child', $page); + } + /** * A constant's description is wrapped, in that order - the wrapper first, * then the text, then the close. @@ -134,11 +155,36 @@ private function config(): Config private function page(): string { - $pages = (new MarkdownFormatter())->format($this->registry(), $this->config()); + $pages = (new MarkdownFormatter())->format($this->registry(), $this->config(), Selection::none()); return $pages['phalcon_sample'] ?? self::fail('phalcon_sample page missing'); } + /** + * The two shared definitions plus one a level deeper, which lands on the + * same page because its path still starts with `Sample/`. + */ + private function nestedRegistry(): Registry + { + $leaf = new ClassDefinition( + new Location('Phalcon\\Sample\\Deep\\Leaf', 'Phalcon\\Sample\\Deep', 'Sample/Deep/Leaf.zep'), + Structure::classType(false, false), + 'The leaf.', + new Imports([], []), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection() + ) + ); + + return new Registry(ClassDefinitionCollection::fromDefinitions([ + ...array_values($this->registry()->definitions()->all()), + $leaf, + ])); + } + private function registry(): Registry { $base = new ClassDefinition( diff --git a/tests/Unit/Model/ModelTest.php b/tests/Unit/Model/ModelTest.php index d96b02a..3cbc5fd 100644 --- a/tests/Unit/Model/ModelTest.php +++ b/tests/Unit/Model/ModelTest.php @@ -34,6 +34,32 @@ final class ModelTest extends TestCase { + /** + * The three spellings a source can use for one parent, all landing on the + * same absolute name. The alias branch is the one a `use` statement makes + * reachable, and it substitutes the import's target for the first segment + * rather than the whole name - `Aliased\Deep` keeps its tail. + */ + public function testQualifyResolvesEverySpelling(): void + { + $imports = new Imports( + ['Phalcon\\Sample\\Support\\Helper'], + ['Aliased' => 'Phalcon\\Sample\\Support\\Helper'] + ); + + $this->assertSame( + '\\Phalcon\\Sample\\Support\\Helper', + $imports->qualify('Aliased', 'Phalcon\\Other') + ); + $this->assertSame( + '\\Phalcon\\Sample\\Support\\Helper\\Deep', + $imports->qualify('Aliased\\Deep', 'Phalcon\\Other') + ); + $this->assertSame('\\Exception', $imports->qualify('\\Exception', 'Phalcon\\Other')); + $this->assertSame('\\Phalcon\\Other\\Sibling', $imports->qualify('Sibling', 'Phalcon\\Other')); + $this->assertSame('\\Loose', $imports->qualify('Loose', '')); + } + public function testClassToArrayNestsChildrenAndCarriesVersion(): void { $array = $this->classDefinition()->toArray(); diff --git a/tests/Unit/Model/RegistryTest.php b/tests/Unit/Model/RegistryTest.php index e1fbf8c..899f2d8 100644 --- a/tests/Unit/Model/RegistryTest.php +++ b/tests/Unit/Model/RegistryTest.php @@ -87,6 +87,73 @@ public function testResolvePrefersTheUseMapOverTheNamespace(): void $this->assertSame('Phalcon\\Base', $registry->resolve('Base', $child)); } + /** + * Each candidate on its own. + * + * The other resolve tests use a registry where the alias, the namespace and + * the root all point at the same definition, so any one of the three could + * be deleted without a test noticing. These isolate them: each name here can + * only be found by the candidate it is named for. + */ + public function testResolveTriesTheAliasThenTheNamespaceThenTheRoot(): void + { + $registry = new Registry( + ClassDefinitionCollection::fromDefinitions([ + // Reachable only through the alias - outside the root, and not + // a sibling of the context. + $this->classDefinition('Vendor\\Aliased'), + // Reachable only as a sibling of the context's namespace. + $this->classDefinition('Deep\\Place\\Sibling'), + // Reachable only under the configured root namespace. + $this->classDefinition('Phalcon\\Rooted'), + ]), + 'Phalcon' + ); + + $context = new ClassDefinition( + new Location('Deep\\Place\\Context', 'Deep\\Place', 'rel.zep'), + Structure::classType(false, false), + '', + new Imports([], ['Shortcut' => 'Vendor\\Aliased']), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection() + ) + ); + + $this->assertSame('Vendor\\Aliased', $registry->resolve('Shortcut', $context)); + $this->assertSame('Deep\\Place\\Sibling', $registry->resolve('Sibling', $context)); + $this->assertSame('Phalcon\\Rooted', $registry->resolve('Rooted', $context)); + } + + /** + * Without a configured root the third candidate is skipped rather than + * built, so a name that only the root could have found stays unresolved. + */ + public function testResolveSkipsTheRootCandidateWhenThereIsNoRoot(): void + { + $registry = new Registry( + ClassDefinitionCollection::fromDefinitions([$this->classDefinition('Phalcon\\Rooted')]) + ); + + $context = new ClassDefinition( + new Location('Deep\\Place\\Context', 'Deep\\Place', 'rel.zep'), + Structure::classType(false, false), + '', + new Imports([], []), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection() + ) + ); + + $this->assertNull($registry->resolve('Rooted', $context)); + } + public function testResolveReturnsNullForUnknownNames(): void { $registry = $this->registry(); diff --git a/tests/Unit/Reader/ReaderEquivalenceTest.php b/tests/Unit/Reader/ReaderEquivalenceTest.php new file mode 100644 index 0000000..e82ba02 --- /dev/null +++ b/tests/Unit/Reader/ReaderEquivalenceTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill\Tests\Unit\Reader; + +use Phalcon\Quill\Config; +use Phalcon\Quill\Reader\ReaderFactory; +use PHPUnit\Framework\TestCase; + +use function dirname; + +/** + * The invariant the whole project rests on: equivalent sources produce + * identical models. + * + * Every other reader test asserts one reader against expected values, which + * says nothing about whether the two agree. This reads one declaration written + * in both languages and compares the models to each other, so a rule applied on + * one side and missed on the other fails here rather than surfacing later as a + * false parity difference between cphalcon and phalcon. + * + * When a construct is added to one fixture, add it to the twin. + */ +final class ReaderEquivalenceTest extends TestCase +{ + public function testTheSameDeclarationReadsIdenticallyFromBothLanguages(): void + { + $this->assertSame($this->read('zephir', 'zep'), $this->read('php', 'php')); + } + + /** + * @return array + */ + private function read(string $language, string $extension): array + { + $config = new Config( + $language, + dirname(__DIR__, 2) . '/Fixtures/twin/' . $extension, + '/unused', + 'phalcon/cphalcon', + '5.0.x', + 'phalcon', + $extension, + 'Phalcon' + ); + + $registry = (new ReaderFactory())->create($language)->read($config); + $class = $registry->get('Phalcon\\Twin\\Subject') + ?? self::fail($language . ' did not read Phalcon\\Twin\\Subject'); + + $document = $class->toArray(); + + // The file extension is the one thing the two are entitled to disagree + // about, and it is the only part of the path that differs. + unset($document['location']['relPath']); + + return $document; + } +} diff --git a/tests/Unit/Reader/ZephirReaderTest.php b/tests/Unit/Reader/ZephirReaderTest.php index de6b5ff..aa6049c 100644 --- a/tests/Unit/Reader/ZephirReaderTest.php +++ b/tests/Unit/Reader/ZephirReaderTest.php @@ -179,6 +179,10 @@ public function testPropertyShortcutsAreRecorded(): void $properties[$property->name] = $property; } + // A declared type with no docblock and no default: the declaration is + // the only thing left to name it, which is where the sources are going. + $this->assertSame('array', $properties['registry']->varType); + $this->assertSame(['get', 'set'], $properties['label']->shortcuts); $this->assertSame([], $properties['counter']->shortcuts); // No keyword at all would be protected; these say so explicitly. diff --git a/tests/Unit/SelectionTest.php b/tests/Unit/SelectionTest.php new file mode 100644 index 0000000..2886690 --- /dev/null +++ b/tests/Unit/SelectionTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Quill\Tests\Unit; + +use Phalcon\Quill\Config; +use Phalcon\Quill\Selection; +use PHPUnit\Framework\TestCase; + +final class SelectionTest extends TestCase +{ + public function testAnEmptyNamespaceMatchesEverything(): void + { + $selection = Selection::none(); + + $this->assertTrue($selection->matchesNamespace('Phalcon\\Config\\Config')); + $this->assertTrue($selection->matchesNamespace('Anything')); + $this->assertFalse($selection->narrows()); + } + + /** + * A prefix test that ignored the separator would pull in every sibling + * whose name merely starts the same way. + */ + public function testMatchingStopsAtTheNamespaceBoundary(): void + { + $selection = Selection::of('', 'Phalcon\\Config', $this->config()); + + $this->assertTrue($selection->matchesNamespace('Phalcon\\Config')); + $this->assertTrue($selection->matchesNamespace('Phalcon\\Config\\Adapter\\Json')); + $this->assertFalse($selection->matchesNamespace('Phalcon\\Configuration')); + $this->assertFalse($selection->matchesNamespace('Phalcon\\Auth\\Adapter\\Config')); + } + + public function testNarrowsReportsEitherDimension(): void + { + $config = $this->config(); + + $this->assertFalse(Selection::of('', '', $config)->narrows()); + $this->assertTrue(Selection::of('adapter', '', $config)->narrows()); + $this->assertTrue(Selection::of('', 'Config', $config)->narrows()); + } + + /** + * Three spellings of the same namespace, because a caller should not have + * to know whether the root is already there. + */ + public function testTheRootNamespaceIsImplied(): void + { + $config = $this->config(); + + $this->assertSame('Phalcon\\Config', Selection::of('', 'Config', $config)->namespace); + $this->assertSame('Phalcon\\Config', Selection::of('', 'Phalcon\\Config', $config)->namespace); + $this->assertSame('Phalcon\\Config', Selection::of('', '\\Phalcon\\Config\\', $config)->namespace); + $this->assertSame('Phalcon', Selection::of('', 'Phalcon', $config)->namespace); + } + + public function testTheFilterIsCarriedUntouched(): void + { + $selection = Selection::of('Adapter', 'Config', $this->config()); + + $this->assertSame('Adapter', $selection->filter); + } + + private function config(): Config + { + return new Config( + 'zephir', + '/unused', + '/unused', + 'phalcon/cphalcon', + '5.0.x', + 'phalcon', + 'zep', + 'Phalcon' + ); + } +}