diff --git a/CHANGELOG.md b/CHANGELOG.md index 478969c..face872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 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.3.0](https://github.com/phalcon/quill/releases/tag/v0.3.0) (2026-08-03) + +### Added + +- The Markdown markup now lives in twenty template files under `resources/templates/markdown`, overridable one file at a time. Output is unchanged. +- The optional `templates` configuration key, naming a directory whose templates are consulted before the shipped ones. A `.tpl` that no lookup can reach - a name outside the shipped set, or a file above the format directory - is reported with the nearest real name and ignored. + +### Changed + +- Rendering one class moved out of `MarkdownFormatter` into `Formatter\Markdown\ClassPage`. + ## [0.2.3](https://github.com/phalcon/quill/releases/tag/v0.2.3) (2026-08-03) ### Fixed diff --git a/README.md b/README.md index 41de567..2eac8bb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ return [ 'prefix' => 'phalcon', 'extension' => 'zep', 'namespace' => 'Phalcon', + 'templates' => 'output/docs/templates', ]; ``` @@ -87,11 +88,47 @@ return [ | `repository`, `branch`, `prefix` | build the "Source on GitHub" link: `https://github.com//blob///` | | `extension` | file extension the reader collects | | `namespace` | root namespace; headings drop it and page names carry it lowercased | +| `templates` | directory holding your own templates; each is looked up there first and falls back to the shipped one. Optional | -`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`, `assets` and `templates` are relative to `quill.php` unless they start with a slash. Every key except `assets` and `templates` 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. +## Templates + +The Markdown formatter emits no markup of its own. Every fragment comes from a file in `resources/templates/markdown`, and `templates` points at a directory of your own that is consulted first, per name. Overriding one template is not vendoring the other nineteen. + +Files go under a directory named for the format, so `templates` set to `docs/templates` means `docs/templates/markdown/class.tpl`. A `.tpl` whose name is not in the shipped set, or one sitting above the format directory, is ignored with a warning naming it and the nearest real name - both would otherwise produce a successful run that applied no override. + +Slots are `{{name}}`, substituted in a single pass: a value that happens to contain `{{title}}` is text, not an instruction. A placeholder a template does not use is ignored, so a template may take fewer slots than it is handed; one it invents is fatal, and `UnknownPlaceholder` names every unsupplied token at once. Loops, ordering and conditionals stay in PHP - a section that renders nothing is handed an empty string rather than asked to decide. + +A template's trailing newline is stripped, exactly one. A fragment whose output must end in a newline is therefore written with a blank final line, which is also what an editor leaves behind. + +| Template | Renders | Placeholders | +|---|---|---| +| `index` | the index page | `lines` | +| `index-line` | one entry on it | `namespace`, `label`, `page` | +| `page` | one page's frontmatter and notice | `namespace`, `classes` | +| `class` | one class's whole section | `title`, `structure`, `badge`, `sourceUrl`, `description`, `tree`, `uses`, `usedBy`, `summary`, `constants`, `properties`, `methods` | +| `class-description` | its prose, when it has any | `description` | +| `tree` | the inheritance block | `lines` | +| `uses` | the import list | `entries` | +| `used-by` | the classes pulling a trait in | `entries` | +| `summary` | the method summary section | `rows` | +| `summary-row` | one summary row | `anchor`, `visibility`, `returnType`, `signature`, `description` | +| `summary-return-type` | its type chip, when the method declares one | `type` | +| `constants` | the constants section | `rows` | +| `constant-row` | one constant | `type`, `name`, `default`, `description` | +| `properties` | the properties section | `rows` | +| `property-row` | one property | `visibility`, `type`, `name`, `default`, `description` | +| `row-description` | the description cell shared by all three row shapes | `description` | +| `methods` | the method detail section | `groups` | +| `method-group` | one visibility group's header and body | `label`, `count`, `methods` | +| `method` | one method's heading and signature block | `name`, `anchor`, `signature`, `description` | +| `method-description` | its prose, when it has any | `description` | + +The class names the templates emit are declared in `Formatter\Markdown\Classes` and styled by `resources/api.css`; a test binds all three, so a name cannot drift out of one of them unnoticed. + ## What `generate` writes - one page per top-level namespace segment, named `_.` @@ -149,6 +186,34 @@ Add `--namespace=` to both sides to compare one subsystem at a time, which keeps `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. +### The full-corpus gate + +The suite proves the rendering over fixtures. The gate proves it over the whole cphalcon tree - roughly 2,600 declarations, which is where a signature shape that appears once and in no fixture turns up. Two directories, both gitignored because committing them buries every source change under a few thousand generated lines: + +| Directory | Role | +|---|---| +| `tests/_baseline` | the expectation - a snapshot of known-good output | +| `tests/_output/gate` | the candidate - where a fresh run writes | + +Run it. `tests/Fixtures/config/cphalcon.php` already points `output` at `gate`, so no `--output` is needed; `phalcon.php` beside it does the same for the PHP implementation: + + docker exec -w /srv quill-8.1 rm -rf tests/_output/gate + docker exec -w /srv quill-8.1 php bin/quill generate --config=tests/Fixtures/config/cphalcon.php + diff -r tests/_baseline tests/_output/gate + +**Silence is the pass.** Any output is a real change to what quill emits and belongs in the CHANGELOG. Delete `gate` first or a page that should have disappeared survives from the previous run and the diff stays quiet about it. + +Regenerating the baseline is the same binary with the destination redirected: + + docker exec -w /srv quill-8.1 php bin/quill generate \ + --config=tests/Fixtures/config/cphalcon.php --output=/srv/tests/_baseline + +Read the diff before you do, and move the old snapshot aside rather than deleting it - it is gitignored, so there is no `git checkout` to undo an `rm`. Regenerating is how an accepted change is recorded; doing it to make the diff go away is how the next one goes unnoticed. + +Two things that catch people out. `bin/quill`, not `vendor/bin/quill` - a package in its own tree has no `vendor/bin` shim. And a redirected run keeps `templates` while dropping `assets`, so the stylesheet follows the pages into the baseline and `api.css` is compared too, while an override still applies - a run that quietly fell back to the shipped templates would produce a clean diff having compared the wrong thing. + +Nothing enforces this. The baseline is refreshed by hand, so it goes stale silently; when the gate reports a difference, check its age against the commits since it was written before assuming the working tree is at fault. + ## License BSD-3-Clause. See [LICENSE](LICENSE). diff --git a/bin/quill b/bin/quill index b9156ce..bff2334 100755 --- a/bin/quill +++ b/bin/quill @@ -186,7 +186,8 @@ try { $command = new GenerateCommand( new ReaderFactory(), - (new FormatterFactory())->create($format) + (new FormatterFactory())->create($format), + $format ); exit($command->execute($config, Selection::of($filter, $namespace, $config))); diff --git a/resources/templates/markdown/class-description.tpl b/resources/templates/markdown/class-description.tpl new file mode 100644 index 0000000..76831a4 --- /dev/null +++ b/resources/templates/markdown/class-description.tpl @@ -0,0 +1,3 @@ + +{{description}} + diff --git a/resources/templates/markdown/class.tpl b/resources/templates/markdown/class.tpl new file mode 100644 index 0000000..15b0c4f --- /dev/null +++ b/resources/templates/markdown/class.tpl @@ -0,0 +1,7 @@ + + +## {{title}} + +{{badge}} +[:material-github: Source on GitHub]({{sourceUrl}}){ .src-btn } +{{description}}{{tree}}{{uses}}{{usedBy}}{{summary}}{{constants}}{{properties}}{{methods}} diff --git a/resources/templates/markdown/constant-row.tpl b/resources/templates/markdown/constant-row.tpl new file mode 100644 index 0000000..7806c12 --- /dev/null +++ b/resources/templates/markdown/constant-row.tpl @@ -0,0 +1,5 @@ +
+{{type}} +{{name}}{{default}} +{{description}}
+ diff --git a/resources/templates/markdown/constants.tpl b/resources/templates/markdown/constants.tpl new file mode 100644 index 0000000..7783be4 --- /dev/null +++ b/resources/templates/markdown/constants.tpl @@ -0,0 +1,6 @@ + +### Constants + +
+{{rows}}
+ diff --git a/resources/templates/markdown/index-line.tpl b/resources/templates/markdown/index-line.tpl new file mode 100644 index 0000000..dfe77d5 --- /dev/null +++ b/resources/templates/markdown/index-line.tpl @@ -0,0 +1,2 @@ +- [{{namespace}} {{label}}]({{page}}.md) + diff --git a/resources/templates/markdown/index.tpl b/resources/templates/markdown/index.tpl new file mode 100644 index 0000000..d5b1681 --- /dev/null +++ b/resources/templates/markdown/index.tpl @@ -0,0 +1,9 @@ +--- +hide: + - toc +--- + +# API Index +- - - + +{{lines}} diff --git a/resources/templates/markdown/method-description.tpl b/resources/templates/markdown/method-description.tpl new file mode 100644 index 0000000..76831a4 --- /dev/null +++ b/resources/templates/markdown/method-description.tpl @@ -0,0 +1,3 @@ + +{{description}} + diff --git a/resources/templates/markdown/method-group.tpl b/resources/templates/markdown/method-group.tpl new file mode 100644 index 0000000..d56db68 --- /dev/null +++ b/resources/templates/markdown/method-group.tpl @@ -0,0 +1,3 @@ + +
{{label}} · {{count}}
+{{methods}} diff --git a/resources/templates/markdown/method.tpl b/resources/templates/markdown/method.tpl new file mode 100644 index 0000000..45555de --- /dev/null +++ b/resources/templates/markdown/method.tpl @@ -0,0 +1,7 @@ + +#### `{{name}}()` { #{{anchor}} } + +```php +{{signature}} +``` +{{description}} diff --git a/resources/templates/markdown/methods.tpl b/resources/templates/markdown/methods.tpl new file mode 100644 index 0000000..72d95a2 --- /dev/null +++ b/resources/templates/markdown/methods.tpl @@ -0,0 +1,3 @@ + +### Methods +{{groups}} diff --git a/resources/templates/markdown/page.tpl b/resources/templates/markdown/page.tpl new file mode 100644 index 0000000..87c8737 --- /dev/null +++ b/resources/templates/markdown/page.tpl @@ -0,0 +1,9 @@ +--- +hide: + - navigation +--- + +!!! info "NOTE" + + All classes are prefixed with `{{namespace}}` +{{classes}} diff --git a/resources/templates/markdown/properties.tpl b/resources/templates/markdown/properties.tpl new file mode 100644 index 0000000..3e10b80 --- /dev/null +++ b/resources/templates/markdown/properties.tpl @@ -0,0 +1,6 @@ + +### Properties + +
+{{rows}}
+ diff --git a/resources/templates/markdown/property-row.tpl b/resources/templates/markdown/property-row.tpl new file mode 100644 index 0000000..454f348 --- /dev/null +++ b/resources/templates/markdown/property-row.tpl @@ -0,0 +1,6 @@ +
+{{visibility}} +{{type}} +${{name}}{{default}} +{{description}}
+ diff --git a/resources/templates/markdown/row-description.tpl b/resources/templates/markdown/row-description.tpl new file mode 100644 index 0000000..b06aa30 --- /dev/null +++ b/resources/templates/markdown/row-description.tpl @@ -0,0 +1,2 @@ +{{description}} + diff --git a/resources/templates/markdown/summary-return-type.tpl b/resources/templates/markdown/summary-return-type.tpl new file mode 100644 index 0000000..376ab6a --- /dev/null +++ b/resources/templates/markdown/summary-return-type.tpl @@ -0,0 +1,2 @@ +{{type}} + diff --git a/resources/templates/markdown/summary-row.tpl b/resources/templates/markdown/summary-row.tpl new file mode 100644 index 0000000..fadbf21 --- /dev/null +++ b/resources/templates/markdown/summary-row.tpl @@ -0,0 +1,5 @@ + +{{visibility}} +{{returnType}}{{signature}} +{{description}} + diff --git a/resources/templates/markdown/summary.tpl b/resources/templates/markdown/summary.tpl new file mode 100644 index 0000000..9d237a9 --- /dev/null +++ b/resources/templates/markdown/summary.tpl @@ -0,0 +1,6 @@ + +### Method Summary + +
+{{rows}}
+ diff --git a/resources/templates/markdown/tree.tpl b/resources/templates/markdown/tree.tpl new file mode 100644 index 0000000..cb3542c --- /dev/null +++ b/resources/templates/markdown/tree.tpl @@ -0,0 +1,7 @@ + +
+ +{{lines}} + +
+ diff --git a/resources/templates/markdown/used-by.tpl b/resources/templates/markdown/used-by.tpl new file mode 100644 index 0000000..72d12f1 --- /dev/null +++ b/resources/templates/markdown/used-by.tpl @@ -0,0 +1,4 @@ + +__Used by__ {{entries}} +{ .api-used-by } + diff --git a/resources/templates/markdown/uses.tpl b/resources/templates/markdown/uses.tpl new file mode 100644 index 0000000..ada5f43 --- /dev/null +++ b/resources/templates/markdown/uses.tpl @@ -0,0 +1,4 @@ + +__Uses__ {{entries}} +{ .api-uses } + diff --git a/src/Cli/GenerateCommand.php b/src/Cli/GenerateCommand.php index 08609f7..c0b9eee 100644 --- a/src/Cli/GenerateCommand.php +++ b/src/Cli/GenerateCommand.php @@ -20,6 +20,7 @@ use Phalcon\Quill\Model\Registry; use Phalcon\Quill\Reader\ReaderFactory; use Phalcon\Quill\Selection; +use Phalcon\Quill\Template\Templates; use function array_keys; use function basename; @@ -46,6 +47,7 @@ final class GenerateCommand public function __construct( private readonly ReaderFactory $factory, private readonly Formatter $formatter, + private readonly string $format, private $stdout = STDOUT, ) { } @@ -56,6 +58,15 @@ public function __construct( */ public function execute(Config $config, Selection $selection): int { + // First, before anything can throw and before the per-page lines. A + // consumer with both a misnamed override and a bad token would + // otherwise get the exception and never see the warning that explains + // it, and a warning printed after the pages is line 31 of a 45-line + // successful run, which nobody reads. + foreach (Templates::unrecognized($this->format, $config->templatesDir()) as $warning) { + fwrite($this->stdout, 'Warning: ' . $warning . PHP_EOL); + } + $reader = $this->factory->create($config->language()); $registry = $reader->read($config); diff --git a/src/Config.php b/src/Config.php index 2d9567e..1029db5 100644 --- a/src/Config.php +++ b/src/Config.php @@ -51,6 +51,7 @@ final class Config private readonly string $assetsDir; private readonly string $outputDir; private readonly string $sourceRoot; + private readonly string $templatesDir; /** * `$assetsDir` defaults to the output directory, which puts a formatter's @@ -68,10 +69,12 @@ public function __construct( private readonly string $extension, private readonly string $rootNamespace, string $assetsDir = '', + string $templatesDir = '', ) { - $this->sourceRoot = rtrim($sourceRoot, '/'); - $this->outputDir = rtrim($outputDir, '/'); - $this->assetsDir = $assetsDir === '' ? $this->outputDir : rtrim($assetsDir, '/'); + $this->sourceRoot = rtrim($sourceRoot, '/'); + $this->outputDir = rtrim($outputDir, '/'); + $this->assetsDir = $assetsDir === '' ? $this->outputDir : rtrim($assetsDir, '/'); + $this->templatesDir = rtrim($templatesDir, '/'); } /** @@ -95,6 +98,11 @@ public static function fromArray(array $config, string $root): self /** @var mixed $assets */ $assets = $config['assets'] ?? null; + // Absent from KEYS on purpose: a project using the shipped templates + // says nothing and takes the constructor's default. + /** @var mixed $templates */ + $templates = $config['templates'] ?? null; + return new self( $values['language'], self::absolute($values['source'], $root), @@ -105,6 +113,7 @@ public static function fromArray(array $config, string $root): self $values['extension'], trim($values['namespace'], '\\'), is_string($assets) && $assets !== '' ? self::absolute($assets, $root) : '', + is_string($templates) && $templates !== '' ? self::absolute($templates, $root) : '', ); } @@ -196,15 +205,29 @@ public function sourceUrl(string $relativePath): string . '/' . str_replace('\\', '/', $relativePath); } + /** + * Where a consumer's own templates live, or '' when they use the shipped + * set. The formatter resolves each template name here first. + */ + public function templatesDir(): string + { + return $this->templatesDir; + } + /** * A copy writing somewhere else, for one-off runs that must not touch the * configured destination. Normal use never needs it. * * The assets directory is not carried over - a redirected run wants - * everything it produces in the one place. + * everything it produces in the one place. The templates directory is, + * because templates are input: a redirected run that fell back to the + * shipped set would not be the same run, and a diff against it would come + * back clean having proved nothing. */ public function withOutputDir(string $outputDir): self { + // Assets are passed empty explicitly in order to reach templates + // positionally - dropping the one and carrying the other in one call. return new self( $this->language, $this->sourceRoot, @@ -214,6 +237,8 @@ public function withOutputDir(string $outputDir): self $this->sourcePrefix, $this->extension, $this->rootNamespace, + '', + $this->templatesDir, ); } } diff --git a/src/Exceptions/MissingTemplate.php b/src/Exceptions/MissingTemplate.php new file mode 100644 index 0000000..385c04f --- /dev/null +++ b/src/Exceptions/MissingTemplate.php @@ -0,0 +1,32 @@ + + * + * 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; + +use function implode; + +class MissingTemplate extends Exception +{ + /** + * @param list $searched + */ + public function __construct(string $name, array $searched) + { + parent::__construct( + "The template '" . $name . "' was not found. Looked in: " + . implode(', ', $searched) . '.' + . ' The shipped set ships with quill, so a copy that cannot find' + . ' it is incomplete rather than misconfigured.' + ); + } +} diff --git a/src/Exceptions/MissingTemplateDirectory.php b/src/Exceptions/MissingTemplateDirectory.php new file mode 100644 index 0000000..0ecde05 --- /dev/null +++ b/src/Exceptions/MissingTemplateDirectory.php @@ -0,0 +1,27 @@ + + * + * 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 MissingTemplateDirectory extends Exception +{ + public function __construct(string $path) + { + parent::__construct( + "The configured templates directory '" . $path . "' is not a" + . ' directory. Remove the `templates` key to use the shipped' + . ' templates, or correct the path - a run that silently ignored' + . ' it would apply no override and still report success.' + ); + } +} diff --git a/src/Exceptions/UnknownPlaceholder.php b/src/Exceptions/UnknownPlaceholder.php new file mode 100644 index 0000000..7e94c24 --- /dev/null +++ b/src/Exceptions/UnknownPlaceholder.php @@ -0,0 +1,32 @@ + + * + * 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; + +use function implode; + +class UnknownPlaceholder extends Exception +{ + /** + * @param list $tokens + */ + public function __construct(string $template, array $tokens) + { + parent::__construct( + "The template '" . $template . "' uses {{" + . implode('}}, {{', $tokens) . '}}, which nothing supplies.' + . ' Placeholders a template does not use are ignored; ones it' + . ' invents are not.' + ); + } +} diff --git a/src/Formatter/Markdown/ClassPage.php b/src/Formatter/Markdown/ClassPage.php new file mode 100644 index 0000000..320c2f1 --- /dev/null +++ b/src/Formatter/Markdown/ClassPage.php @@ -0,0 +1,396 @@ + + * + * 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\Model\ClassDefinition; +use Phalcon\Quill\Model\Keyword; +use Phalcon\Quill\Model\MethodDefinitionCollection; +use Phalcon\Quill\Model\Registry; +use Phalcon\Quill\Template\Templates; + +use function array_map; +use function count; +use function explode; +use function implode; +use function sort; +use function str_repeat; +use function str_starts_with; +use function trim; +use function ucfirst; + +/** + * One class's section of a page: the heading, the badge, the inheritance tree + * and every member section under it. + * + * Which templates are rendered, in what order, and what a section is skipped + * for stays here - the templates carry the markup and nothing else. Everything + * a section needs that does not vary per class is constructor state, so the + * methods below take a definition and nothing more. + * + * Built fresh per format() call, because the registry and the presentation it + * closes over belong to that run. + */ +final class ClassPage +{ + public function __construct( + private readonly Templates $templates, + private readonly Presentation $view, + private readonly Registry $registry, + private readonly Naming $naming, + private readonly Signature $signature, + private readonly Html $html, + ) { + } + + public function render(ClassDefinition $class): string + { + $badge = 'Class'; + $structure = 'class'; + + if ($class->structure->keyword === Keyword::Interface) { + $badge = 'Interface'; + $structure = 'interface'; + } elseif ($class->structure->keyword === Keyword::Trait) { + $badge = 'Trait'; + $structure = 'trait'; + } elseif ($class->structure->isAbstract === true) { + $badge = 'Abstract'; + $structure = 'abstract'; + } elseif ($class->structure->isFinal === true) { + $badge = 'Final'; + $structure = 'final'; + } + + $description = ''; + if ($class->description !== '') { + $description = $this->templates->render('class-description', [ + 'description' => $class->description, + ]); + } + + return $this->templates->render('class', [ + 'badge' => $badge, + 'constants' => $this->constants($class), + 'description' => $description, + 'methods' => $this->methodDetails($class), + 'properties' => $this->properties($class), + 'sourceUrl' => $this->view->sourceUrl($class->location->relPath), + 'structure' => $structure, + 'summary' => $this->summary($class), + 'title' => $this->naming->title($class, $this->view), + 'tree' => $this->tree($class), + 'usedBy' => $this->usedBy($class), + 'uses' => $this->uses($class), + ]); + } + + private function constants(ClassDefinition $class): string + { + if ($class->members->constants->isEmpty()) { + return ''; + } + + $rows = ''; + foreach ($class->members->constants as $constant) { + $rows .= $this->templates->render('constant-row', [ + 'default' => $this->html->default($constant->default), + 'description' => $this->rowDescription($constant->description), + 'name' => $this->html->escape($constant->name), + 'type' => $this->html->escape($constant->varType), + ]); + } + + return $this->templates->render('constants', ['rows' => $rows]); + } + + /** + * Markdown link to a class when it is in the registry, plain code when not. + */ + private function fqcnLink(string $display, ?string $fqcn, string $currentPage): string + { + $target = $fqcn === null ? null : $this->registry->get($fqcn); + if ($target === null) { + return "`{$display}`"; + } + + $href = '#' . $this->naming->anchor($target, $this->view); + $targetPage = $this->naming->pageKey($target, $this->view); + if ($targetPage !== $currentPage) { + $href = $targetPage . '.md' . $href; + } + + return "[`{$display}`]({$href})"; + } + + private function methodDetails(ClassDefinition $class): string + { + $groups = $this->orderMethods($class->members->methods); + if ($groups === null) { + return ''; + } + + $rendered = ''; + + foreach (['public', 'protected'] as $group) { + if ($groups[$group]->isEmpty()) { + continue; + } + + $methods = ''; + foreach ($groups[$group] as $method) { + $description = ''; + if ($method->description !== '') { + $description = $this->templates->render('method-description', [ + 'description' => $method->description, + ]); + } + + $methods .= $this->templates->render('method', [ + 'anchor' => $this->naming->methodAnchor($class, $method->name, $this->view), + 'description' => $description, + 'name' => $method->name, + 'signature' => implode("\n", $this->signature->lines($method)), + ]); + } + + $rendered .= $this->templates->render('method-group', [ + 'count' => (string) $groups[$group]->count(), + 'label' => ucfirst($group), + 'methods' => $methods, + ]); + } + + return $this->templates->render('methods', ['groups' => $rendered]); + } + + /** + * Private methods dropped, reserved (__*) first, then alphabetical, split + * 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. Both sections that + * render methods open with this question, so both ask it here. + * + * @return array{public: MethodDefinitionCollection, protected: MethodDefinitionCollection}|null + */ + private function orderMethods(MethodDefinitionCollection $methods): ?array + { + $visible = $methods->withoutPrivate()->ordered(); + + $groups = [ + 'public' => $visible->withVisibility('public'), + 'protected' => $visible->withVisibility('protected'), + ]; + + if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) { + return null; + } + + return $groups; + } + + private function properties(ClassDefinition $class): string + { + $visible = $class->members->properties->withoutPrivate(); + if ($visible->isEmpty()) { + return ''; + } + + $rows = ''; + foreach ($visible as $property) { + $rows .= $this->templates->render('property-row', [ + 'default' => $this->html->default($property->default), + 'description' => $this->rowDescription($property->description), + 'name' => $this->html->escape($property->name), + 'type' => $this->html->escape($property->varType), + 'visibility' => $property->visibility, + ]); + } + + return $this->templates->render('properties', ['rows' => $rows]); + } + + /** + * 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, string $currentPage): string + { + $links = []; + foreach ($names as $name) { + $fqcn = $this->registry->resolve($name, $class); + $links[] = $this->fqcnLink($fqcn ?? $name, $fqcn, $currentPage); + } + + return implode(', ', $links); + } + + /** + * The description cell a member row carries, or empty when it has none. + * + * Shared by all three row shapes: the markup is identical, and only the + * question of whether there is anything to say differs. + */ + private function rowDescription(string $description): string + { + if ($description === '') { + return ''; + } + + return $this->templates->render('row-description', [ + 'description' => $this->html->inlineCode($description), + ]); + } + + private function summary(ClassDefinition $class): string + { + $groups = $this->orderMethods($class->members->methods); + if ($groups === null) { + return ''; + } + + $rows = ''; + + foreach (['public', 'protected'] as $group) { + foreach ($groups[$group] as $method) { + $returnType = ''; + if ($method->returnType !== null) { + $returnType = $this->templates->render('summary-return-type', [ + 'type' => $this->html->escape($method->returnType), + ]); + } + + $rows .= $this->templates->render('summary-row', [ + 'anchor' => $this->naming->methodAnchor($class, $method->name, $this->view), + 'description' => $this->rowDescription($this->summaryLine($method->description)), + 'returnType' => $returnType, + 'signature' => $this->signature->inline($method), + 'visibility' => $group, + ]); + } + } + + return $this->templates->render('summary', ['rows' => $rows]); + } + + /** + * First prose line of a description, used in the summary rows. + */ + private function summaryLine(string $description): string + { + foreach (explode("\n", $description) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (str_starts_with($line, '```')) { + return ''; + } + + return $line; + } + + return ''; + } + + private function tree(ClassDefinition $class): string + { + $currentPage = $this->naming->pageKey($class, $this->view); + $level = 0; + $lines = []; + + foreach ($this->registry->ancestorsOf($class) as $ancestor) { + $lines[] = str_repeat(' ', $level * 4) . '- ' + . $this->fqcnLink($ancestor['display'], $ancestor['fqcn'], $currentPage); + $level++; + } + + $current = str_repeat(' ', $level * 4) . "- **`{$class->location->fqcn}`**"; + + $annotations = []; + if ( + $class->structure->keyword === Keyword::Interface + && count($class->relations->extends) > 1 + ) { + $annotations[] = 'extends ' + . $this->relationLinks($class->relations->extends, $class, $currentPage); + } + + if ($class->relations->implements !== []) { + $annotations[] = 'implements ' + . $this->relationLinks($class->relations->implements, $class, $currentPage); + } + + if ($annotations !== []) { + $current .= ' - ' . implode('; ', $annotations); + } + + $lines[] = $current; + $level++; + + $children = $this->registry->childrenOf($class); + sort($children); + foreach ($children as $child) { + $lines[] = str_repeat(' ', $level * 4) . '- ' + . $this->fqcnLink($child, $child, $currentPage); + } + + return $this->templates->render('tree', ['lines' => implode("\n", $lines)]); + } + + /** + * The inverse of a trait's usage: which classes pull it in. Rendered as + * links because, unlike the import list, every target is by construction + * in the registry. + */ + private function usedBy(ClassDefinition $class): string + { + $users = $this->registry->usedBy($class); + if ($users === []) { + return ''; + } + + sort($users); + + $currentPage = $this->naming->pageKey($class, $this->view); + + $links = array_map( + fn (string $fqcn): string => $this->fqcnLink($fqcn, $fqcn, $currentPage), + $users + ); + + return $this->templates->render('used-by', ['entries' => implode(' · ', $links)]); + } + + private function uses(ClassDefinition $class): string + { + if ($class->imports->uses === []) { + return ''; + } + + $uses = $class->imports->uses; + sort($uses); + + $codes = array_map( + static fn (string $use): string => "`{$use}`", + $uses + ); + + return $this->templates->render('uses', ['entries' => implode(' · ', $codes)]); + } +} diff --git a/src/Formatter/Markdown/Classes.php b/src/Formatter/Markdown/Classes.php index 6f7b9d2..b3f4089 100644 --- a/src/Formatter/Markdown/Classes.php +++ b/src/Formatter/Markdown/Classes.php @@ -22,6 +22,12 @@ * 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. * + * Five are still read at render time - PARAMETER, TOKEN_FUNCTION, TOKEN_MUTED, + * TOKEN_TYPE and TOKEN_VARIABLE, by Signature and Html, which build their + * markup in PHP. The rest are declarations: the markup that carries them now + * lives in `resources/templates/markdown`, and MarkupContractTest binds the + * two together so a name can no longer drift out of either side unnoticed. + * * Changing a value is a breaking change for the stylesheet. Adding one means * adding a rule to api.css in the same commit. */ diff --git a/src/Formatter/MarkdownFormatter.php b/src/Formatter/MarkdownFormatter.php index ce004df..24982b3 100644 --- a/src/Formatter/MarkdownFormatter.php +++ b/src/Formatter/MarkdownFormatter.php @@ -16,45 +16,36 @@ 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\ClassPage; 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 Phalcon\Quill\Template\Templates; use function array_keys; -use function array_map; -use function count; use function dirname; -use function explode; use function file_get_contents; -use function implode; use function ksort; use function sort; -use function str_repeat; use function str_replace; -use function str_starts_with; use function stripos; -use function trim; use function ucfirst; -use const PHP_EOL; - /** * Emits the mkdocs Markdown the documentation repositories consume. * - * The exact whitespace and markup here is load-bearing. These documents are - * diffed between the two Phalcon implementations, so an incidental formatting - * change reads as an API change. Alter the rendering deliberately, never for + * The exact whitespace and markup is load-bearing. These documents are diffed + * between the two Phalcon implementations, so an incidental formatting change + * reads as an API change. It lives in `resources/templates/markdown`, which a + * project can override a file at a time; alter it deliberately, never for * tidiness. * - * Naming, escaping and signature rendering live in the Markdown namespace; - * what stays here is page assembly and the section layout. + * Naming, escaping and signature rendering live in the Markdown namespace, and + * a class's own section is ClassPage's. What stays here is which pages exist, + * what goes on each, and the index. */ final class MarkdownFormatter implements Formatter { @@ -100,9 +91,10 @@ public function extension(): string */ public function format(Registry $registry, Config $config, Selection $selection): array { - $view = Presentation::from($config); - $pages = $this->pages($registry, $view, $selection); - $output = []; + $templates = Templates::for('markdown', $config->templatesDir()); + $view = Presentation::from($config); + $pages = $this->pages($registry, $view, $selection); + $output = []; foreach (array_keys($pages) as $page) { if ($selection->filter !== '' && stripos($page, $selection->filter) === false) { @@ -110,230 +102,47 @@ public function format(Registry $registry, Config $config, Selection $selection) } } - $index = <<indexLine($page, $view); + $lines .= $this->indexLine($page, $view, $templates); } - $output['index'] = $index; + $output['index'] = $templates->render('index', ['lines' => $lines]); - foreach ($pages as $page => $fqcns) { - $document = <<rootNamespace}` - - EOT; + $classPage = new ClassPage( + $templates, + $view, + $registry, + $this->naming, + $this->signature, + $this->html + ); + foreach ($pages as $page => $fqcns) { + $classes = ''; foreach ($fqcns as $fqcn) { $class = $registry->get($fqcn); if ($class !== null) { - $document .= $this->classDoc($class, $registry, $view); + $classes .= $classPage->render($class); } } - $output[$page] = $document; - } - - return $output; - } - - private function classDoc(ClassDefinition $class, Registry $registry, Presentation $view): string - { - $badge = 'Class'; - $css = 'class'; - - if ($class->structure->keyword === Keyword::Interface) { - $badge = 'Interface'; - $css = 'interface'; - } elseif ($class->structure->keyword === Keyword::Trait) { - $badge = 'Trait'; - $css = 'trait'; - } elseif ($class->structure->isAbstract === true) { - $badge = 'Abstract'; - $css = 'abstract'; - } elseif ($class->structure->isFinal === true) { - $badge = 'Final'; - $css = 'final'; - } - - $badgeClass = Classes::BADGE . ' ' . Classes::BADGE_PREFIX . $css; - - $output = "\n\n## " . $this->naming->title($class, $view) . "\n\n" - . "{$badge}\n" - . "[:material-github: Source on GitHub]" - . '(' . $view->sourceUrl($class->location->relPath) . ')' - . '{ .' . Classes::SOURCE_BUTTON . " }\n"; - - if ($class->description !== '') { - $output .= "\n" . $class->description . "\n"; - } - - $output .= $this->tree($class, $registry, $view); - $output .= $this->uses($class); - $output .= $this->usedBy($class, $registry, $view); - $output .= $this->summary($class, $view); - $output .= $this->constants($class); - $output .= $this->properties($class); - $output .= $this->methodDetails($class, $view); - - return $output; - } - - private function constants(ClassDefinition $class): string - { - if ($class->members->constants->isEmpty()) { - return ''; - } - - $output = "\n### Constants\n\n" . $this->openList(); - foreach ($class->members->constants as $constant) { - $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"; - } - - /** - * Markdown link to a class when it is in the registry, plain code when not. - */ - private function fqcnLink( - string $display, - ?string $fqcn, - Registry $registry, - Presentation $view, - string $currentPage - ): string { - $target = $fqcn === null ? null : $registry->get($fqcn); - if ($target === null) { - return "`{$display}`"; - } - - $href = '#' . $this->naming->anchor($target, $view); - $targetPage = $this->naming->pageKey($target, $view); - if ($targetPage !== $currentPage) { - $href = $targetPage . '.md' . $href; - } - - return "[`{$display}`]({$href})"; - } - - private function indexLine(string $page, Presentation $view): string - { - $label = ucfirst(str_replace($view->pagePrefix, '', $page)); - - return '- [' . $view->rootNamespace . ' ' . $label . ']' - . '(' . $page . '.md)' . PHP_EOL; - } - - /** - * A labelled run of inline entries under a class - the shape both the - * import list and the trait's users are rendered in. Only the envelope is - * shared; what the entries are is the caller's business. - * - * @param list $entries already rendered, in display order - */ - private function inlineList(string $label, array $entries, string $cssClass): string - { - return "\n__" . $label . '__ ' . implode(' · ', $entries) - . "\n{ ." . $cssClass . " }\n"; - } - - private function methodDetails(ClassDefinition $class, Presentation $view): string - { - $groups = $this->orderMethods($class->members->methods); - if ($groups === null) { - return ''; - } - - $output = "\n### Methods\n"; - - foreach (['public', 'protected'] as $group) { - if ($groups[$group]->isEmpty()) { - continue; - } - - $count = $groups[$group]->count(); - $label = ucfirst($group); - $groupClass = Classes::GROUP; - $output .= "\n
{$label} · {$count}
\n"; - - foreach ($groups[$group] as $method) { - $anchor = $this->naming->methodAnchor($class, $method->name, $view); - $signature = implode("\n", $this->signature->lines($method)); - - $output .= "\n#### `{$method->name}()` { #{$anchor} }\n\n" - . "```php\n{$signature}\n```\n"; - - if ($method->description !== '') { - $output .= "\n" . $method->description . "\n"; - } - } + $output[$page] = $templates->render('page', [ + 'classes' => $classes, + 'namespace' => $view->rootNamespace, + ]); } 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. 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. Both sections that - * render methods open with this question, so both ask it here. - * - * @return array{public: MethodDefinitionCollection, protected: MethodDefinitionCollection}|null - */ - private function orderMethods(MethodDefinitionCollection $methods): ?array + private function indexLine(string $page, Presentation $view, Templates $templates): string { - $visible = $methods->withoutPrivate()->ordered(); - - $groups = [ - 'public' => $visible->withVisibility('public'), - 'protected' => $visible->withVisibility('protected'), - ]; - - if ($groups['public']->isEmpty() && $groups['protected']->isEmpty()) { - return null; - } - - return $groups; + return $templates->render('index-line', [ + 'label' => ucfirst(str_replace($view->pagePrefix, '', $page)), + 'namespace' => $view->rootNamespace, + 'page' => $page, + ]); } /** @@ -364,243 +173,4 @@ private function pages(Registry $registry, Presentation $view, Selection $select return $pages; } - - private function properties(ClassDefinition $class): string - { - $visible = $class->members->properties->withoutPrivate(); - if ($visible->isEmpty()) { - return ''; - } - - $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 - ); - } - - return $output . "
\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); - } - - 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, Presentation $view): string - { - $groups = $this->orderMethods($class->members->methods); - if ($groups === null) { - return ''; - } - - $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, $view); - $itemClass = Classes::ITEM; - $visClass = Classes::VISIBILITY . ' ' . Classes::VISIBILITY_PREFIX . $group; - - $output .= "\n" - . "{$group}\n"; - - if ($method->returnType !== null) { - $output .= '' - . $this->html->escape($method->returnType) . "\n"; - } - - $output .= '' - . $this->signature->inline($method) . "\n"; - - $line = $this->summaryLine($method->description); - if ($line !== '') { - $output .= '' - . $this->html->inlineCode($line) . "\n"; - } - - $output .= "\n"; - } - } - - return $output . "\n"; - } - - /** - * First prose line of a description, used in the summary rows. - */ - private function summaryLine(string $description): string - { - foreach (explode("\n", $description) as $line) { - $line = trim($line); - if ($line === '') { - continue; - } - if (str_starts_with($line, '```')) { - return ''; - } - - return $line; - } - - return ''; - } - - private function tree( - ClassDefinition $class, - Registry $registry, - Presentation $view - ): string { - $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, - $view, - $currentPage - ); - $level++; - } - - $current = str_repeat(' ', $level * 4) . "- **`{$class->location->fqcn}`**"; - - $annotations = []; - if ( - $class->structure->keyword === Keyword::Interface - && count($class->relations->extends) > 1 - ) { - $annotations[] = 'extends ' - . $this->relationLinks( - $class->relations->extends, - $class, - $registry, - $view, - $currentPage - ); - } - - if ($class->relations->implements !== []) { - $annotations[] = 'implements ' - . $this->relationLinks( - $class->relations->implements, - $class, - $registry, - $view, - $currentPage - ); - } - - if ($annotations !== []) { - $current .= ' - ' . implode('; ', $annotations); - } - - $lines[] = $current; - $level++; - - $children = $registry->childrenOf($class); - sort($children); - foreach ($children as $child) { - $lines[] = str_repeat(' ', $level * 4) . '- ' - . $this->fqcnLink($child, $child, $registry, $view, $currentPage); - } - - return "\n
\n\n" - . implode("\n", $lines) - . "\n\n
\n"; - } - - /** - * The inverse of a trait's usage: which classes pull it in. Rendered as - * links because, unlike the import list, every target is by construction - * in the registry. - */ - private function usedBy(ClassDefinition $class, Registry $registry, Presentation $view): string - { - $users = $registry->usedBy($class); - if ($users === []) { - return ''; - } - - sort($users); - - $currentPage = $this->naming->pageKey($class, $view); - - $links = array_map( - fn (string $fqcn): string => $this->fqcnLink($fqcn, $fqcn, $registry, $view, $currentPage), - $users - ); - - return $this->inlineList('Used by', $links, Classes::USED_BY); - } - - private function uses(ClassDefinition $class): string - { - if ($class->imports->uses === []) { - return ''; - } - - $uses = $class->imports->uses; - sort($uses); - - $codes = array_map( - static fn (string $use): string => "`{$use}`", - $uses - ); - - return $this->inlineList('Uses', $codes, Classes::USES); - } } diff --git a/src/Template/Templates.php b/src/Template/Templates.php new file mode 100644 index 0000000..4daa2c0 --- /dev/null +++ b/src/Template/Templates.php @@ -0,0 +1,217 @@ + + * + * 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\Template; + +use Phalcon\Quill\Exceptions\MissingTemplate; +use Phalcon\Quill\Exceptions\MissingTemplateDirectory; +use Phalcon\Quill\Exceptions\UnknownPlaceholder; + +use function array_diff; +use function array_keys; +use function array_unique; +use function array_values; +use function basename; +use function dirname; +use function file_get_contents; +use function glob; +use function in_array; +use function is_dir; +use function is_file; +use function levenshtein; +use function preg_match_all; +use function str_ends_with; +use function strtr; +use function substr; + +use const PHP_EOL; + +/** + * A template name resolved to a file and its `{{name}}` placeholders filled. + * + * A consumer's own directory is consulted before the shipped one, per name, so + * overriding one template does not mean vendoring the other nineteen. + * + * Substitution is a single pass: a value that happens to contain `{{title}}` + * is text, not an instruction. Loops, ordering and conditionals stay in PHP - + * this deliberately is not a template language. + */ +final class Templates +{ + private const EXTENSION = '.tpl'; + private const PLACEHOLDER = '/\{\{([a-zA-Z][a-zA-Z0-9]*)\}\}/'; + + /** @var array}> */ + private array $cache = []; + + private function __construct( + private readonly string $format, + private readonly string $customDir, + private readonly string $shippedDir, + ) { + } + + public static function for(string $format, string $templatesDir): self + { + self::guard($templatesDir); + + return new self( + $format, + $templatesDir === '' ? '' : $templatesDir . '/' . $format, + self::shipped($format) + ); + } + + /** + * @param array $values + */ + public function render(string $name, array $values): string + { + $template = $this->load($name); + + $missing = array_values(array_diff($template['tokens'], array_keys($values))); + if ($missing !== []) { + throw new UnknownPlaceholder($this->format . '/' . $name, $missing); + } + + $pairs = []; + foreach ($values as $key => $value) { + $pairs['{{' . $key . '}}'] = $value; + } + + return strtr($template['body'], $pairs); + } + + /** + * Files in the custom directory that no lookup will ever reach: a name + * outside the shipped set, or a template sitting above the format + * directory. Both otherwise produce a successful run that applied no + * override, which reads as the feature working. + * + * @return list + */ + public static function unrecognized(string $format, string $templatesDir): array + { + self::guard($templatesDir); + + if ($templatesDir === '') { + return []; + } + + $known = []; + foreach (glob(self::shipped($format) . '/*' . self::EXTENSION) ?: [] as $path) { + $known[] = basename($path, self::EXTENSION); + } + + // A format with no shipped templates has no set to be outside of, so a + // json run with `templates` configured warns about nothing. + if ($known === []) { + return []; + } + + $warnings = []; + + foreach (glob($templatesDir . '/' . $format . '/*' . self::EXTENSION) ?: [] as $path) { + $name = basename($path, self::EXTENSION); + if (in_array($name, $known, true)) { + continue; + } + + $warnings[] = "'" . basename($path) . "' matches no known template and was ignored." + . PHP_EOL . ' Did you mean ' . "'" . self::nearest($name, $known) . ".tpl'?"; + } + + foreach (glob($templatesDir . '/*' . self::EXTENSION) ?: [] as $path) { + $warnings[] = "'" . basename($path) . "' sits outside '" . $format . "/' and was ignored." + . PHP_EOL . ' Move it to ' . $templatesDir . '/' . $format . '/' . basename($path); + } + + return $warnings; + } + + private static function guard(string $templatesDir): void + { + if ($templatesDir !== '' && !is_dir($templatesDir)) { + throw new MissingTemplateDirectory($templatesDir); + } + } + + /** + * @param list $known + */ + private static function nearest(string $name, array $known): string + { + $best = $known[0]; + $distance = levenshtein($name, $best); + + foreach ($known as $candidate) { + $current = levenshtein($name, $candidate); + if ($current < $distance) { + $best = $candidate; + $distance = $current; + } + } + + return $best; + } + + private static function shipped(string $format): string + { + return dirname(__DIR__, 2) . '/resources/templates/' . $format; + } + + /** + * @return array{body: string, tokens: list} + */ + private function load(string $name): array + { + if (isset($this->cache[$name])) { + return $this->cache[$name]; + } + + $searched = []; + $body = null; + + foreach ([$this->customDir, $this->shippedDir] as $directory) { + if ($directory === '') { + continue; + } + + $path = $directory . '/' . $name . self::EXTENSION; + $searched[] = $path; + + if (is_file($path)) { + $body = (string) file_get_contents($path); + + break; + } + } + + if ($body === null) { + throw new MissingTemplate($name, $searched); + } + + // Exactly one. Editors and .editorconfig add a trailing newline, so a + // template whose output must end in one is written with a blank line. + if (str_ends_with($body, "\n")) { + $body = substr($body, 0, -1); + } + + preg_match_all(self::PLACEHOLDER, $body, $matches); + + return $this->cache[$name] = [ + 'body' => $body, + 'tokens' => array_values(array_unique($matches[1])), + ]; + } +} diff --git a/tests/Fixtures/golden/phalcon_sample.md b/tests/Fixtures/golden/phalcon_sample.md new file mode 100644 index 0000000..f39c012 --- /dev/null +++ b/tests/Fixtures/golden/phalcon_sample.md @@ -0,0 +1,175 @@ +--- +hide: + - navigation +--- + +!!! info "NOTE" + + All classes are prefixed with `Phalcon` + + +## Sample\Base + +Abstract +[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Base.zep){ .src-btn } + +The abstract root. + +
+ +- **`Phalcon\Sample\Base`** + - [`Phalcon\Sample\Child`](#samplechild) + +
+ +### Method Summary + + + +### Constants + +
+
+int +LIMIT = 10 +How many. +
+
+string +MARKER +
+
+ +### Properties + +
+
+protected +string|null +$label = null +The label. +
+
+public +array +$store +
+
+ +### Methods + +
Public · 1
+ +#### `describe()` { #samplebase-describe } + +```php +public function describe( + string $text = 'none', + int $depth = 0 +): string; +``` + +Describes the subject. + +
Protected · 1
+ +#### `conceal()` { #samplebase-conceal } + +```php +protected function conceal( bool $flag ); +``` + + +## Sample\Child + +Final +[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Child.zep){ .src-btn } + +The concrete leaf. + +
+ +- [`Phalcon\Sample\Base`](#samplebase) + - **`Phalcon\Sample\Child`** - implements [`Phalcon\Sample\Contract`](#samplecontract) + +
+ +__Uses__ `Phalcon\Sample\Base` · `Phalcon\Sample\Contract` +{ .api-uses } + + +## Sample\Contract + +Interface +[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Contract.zep){ .src-btn } + +What the leaf promises. + +
+ +- **`Phalcon\Sample\Contract`** + +
+ +### Method Summary + + + +### Methods + +
Public · 1
+ +#### `describe()` { #samplecontract-describe } + +```php +public function describe( string $text ): string; +``` + +Describes the subject. + + +## Sample\Helper + +Trait +[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Helper.zep){ .src-btn } + +Shared behavior. + +
+ +- **`Phalcon\Sample\Helper`** + +
+ +__Used by__ [`Phalcon\Sample\Child`](#samplechild) +{ .api-used-by } + + +## Sample\Plain + +Class +[:material-github: Source on GitHub](https://github.com/phalcon/cphalcon/blob/5.0.x/phalcon/Sample/Plain.zep){ .src-btn } + +
+ +- **`Phalcon\Sample\Plain`** + +
diff --git a/tests/Fixtures/templates/markdown/class.tpl b/tests/Fixtures/templates/markdown/class.tpl new file mode 100644 index 0000000..ba53e73 --- /dev/null +++ b/tests/Fixtures/templates/markdown/class.tpl @@ -0,0 +1 @@ +CUSTOM {{title}} diff --git a/tests/Fixtures/templates/markdown/constants-row.tpl b/tests/Fixtures/templates/markdown/constants-row.tpl new file mode 100644 index 0000000..ba53e73 --- /dev/null +++ b/tests/Fixtures/templates/markdown/constants-row.tpl @@ -0,0 +1 @@ +CUSTOM {{title}} diff --git a/tests/Fixtures/templates/markdown/echo.tpl b/tests/Fixtures/templates/markdown/echo.tpl new file mode 100644 index 0000000..2e0ede7 --- /dev/null +++ b/tests/Fixtures/templates/markdown/echo.tpl @@ -0,0 +1 @@ +literal {{value}} here diff --git a/tests/Fixtures/templates/markdown/pair.tpl b/tests/Fixtures/templates/markdown/pair.tpl new file mode 100644 index 0000000..69e85fd --- /dev/null +++ b/tests/Fixtures/templates/markdown/pair.tpl @@ -0,0 +1 @@ +{{alpha}} {{beta}} diff --git a/tests/Fixtures/templates/markdown/repeat.tpl b/tests/Fixtures/templates/markdown/repeat.tpl new file mode 100644 index 0000000..1e816f7 --- /dev/null +++ b/tests/Fixtures/templates/markdown/repeat.tpl @@ -0,0 +1 @@ +{{word}} and {{word}} again diff --git a/tests/Fixtures/templates/markdown/trailing.tpl b/tests/Fixtures/templates/markdown/trailing.tpl new file mode 100644 index 0000000..1806d48 --- /dev/null +++ b/tests/Fixtures/templates/markdown/trailing.tpl @@ -0,0 +1,2 @@ +

x

+ diff --git a/tests/Fixtures/templates/markdown/verbatim.tpl b/tests/Fixtures/templates/markdown/verbatim.tpl new file mode 100644 index 0000000..a43a42f --- /dev/null +++ b/tests/Fixtures/templates/markdown/verbatim.tpl @@ -0,0 +1 @@ +nothing to substitute diff --git a/tests/Fixtures/templates/misplaced.tpl b/tests/Fixtures/templates/misplaced.tpl new file mode 100644 index 0000000..cbba15e --- /dev/null +++ b/tests/Fixtures/templates/misplaced.tpl @@ -0,0 +1 @@ +wrong place diff --git a/tests/Fixtures/zep/Shapes.zep b/tests/Fixtures/zep/Shapes.zep index e9a69c6..b4c2309 100644 --- a/tests/Fixtures/zep/Shapes.zep +++ b/tests/Fixtures/zep/Shapes.zep @@ -36,6 +36,10 @@ abstract class Shapes extends Consumer implements Countable, Stringable // only thing that can name the type. protected array registry; + // A declared type with a null default, which is the only union Zephir can + // express. The PHP twin spells the same thing `?string`. + protected string title = null; + public function withCast( item) -> { return item; diff --git a/tests/Unit/Cli/GenerateCommandTest.php b/tests/Unit/Cli/GenerateCommandTest.php index a072b86..bad1fff 100644 --- a/tests/Unit/Cli/GenerateCommandTest.php +++ b/tests/Unit/Cli/GenerateCommandTest.php @@ -33,13 +33,19 @@ use function glob; use function is_dir; use function is_file; +use function rewind; use function rmdir; +use function stream_get_contents; +use function strpos; use function unlink; final class GenerateCommandTest extends TestCase { private string $outputDir = ''; + /** @var resource|null */ + private $stdout = null; + protected function setUp(): void { parent::setUp(); @@ -125,6 +131,28 @@ public function testANamespaceMatchingNothingFailsBeforeAnythingIsWritten(): voi } } + /** + * The position is load-bearing: format() can throw on a bad placeholder, + * and a warning printed after the per-page lines is buried in a successful + * run. + */ + public function testAnUnrecognizedOverrideWarnsBeforeAnyPage(): void + { + $this->command()->execute( + $this->config('', dirname(__DIR__, 2) . '/Fixtures/templates'), + Selection::none() + ); + + $output = $this->emitted(); + + $warningAt = strpos($output, 'Warning:'); + $processingAt = strpos($output, 'Processing:'); + + $this->assertNotFalse($warningAt); + $this->assertNotFalse($processingAt); + $this->assertLessThan($processingAt, $warningAt); + } + public function testAnUnwritableAssetFailsLoudly(): void { $assets = $this->outputDir . '/assets'; @@ -223,19 +251,22 @@ private function clean(): void $this->remove($this->outputDir); } - private function command(?Formatter $formatter = null): GenerateCommand + private function command(?Formatter $formatter = null, string $format = 'markdown'): GenerateCommand { $stdout = fopen('php://memory', 'rb+'); $this->assertIsResource($stdout); + $this->stdout = $stdout; + return new GenerateCommand( new ReaderFactory(), $formatter ?? new MarkdownFormatter(), + $format, $stdout ); } - private function config(string $assetsDir = ''): Config + private function config(string $assetsDir = '', string $templatesDir = ''): Config { return new Config( 'zephir', @@ -246,10 +277,24 @@ private function config(string $assetsDir = ''): Config 'phalcon', 'zep', 'Phalcon', - $assetsDir + $assetsDir, + $templatesDir ); } + /** + * Everything the last command() built wrote to its stdout. + */ + private function emitted(): string + { + $stdout = $this->stdout; + $this->assertIsResource($stdout); + + rewind($stdout); + + return (string) stream_get_contents($stdout); + } + /** * Depth first, because a run can write into nested directories now that the * assets destination need not be the one the documents go in. diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 085269d..6719776 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -50,6 +50,13 @@ public function testAConfiguredAssetsDirectoryIsResolvedAgainstTheRoot(): void $this->assertSame('/project/docs/assets/css', $config->assetsDir()); } + public function testAnAbsoluteTemplatesPathWins(): void + { + $config = Config::fromArray($this->values(['templates' => '/elsewhere/tpl']), '/project'); + + $this->assertSame('/elsewhere/tpl', $config->templatesDir()); + } + /** * 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 @@ -76,6 +83,22 @@ public function testAnEmptyOrAbsentAssetsKeyFallsBackToTheOutputDirectory(): voi $this->assertSame($empty->outputDir(), $empty->assetsDir()); } + public function testARedirectedRunKeepsTemplatesAndDropsAssets(): void + { + $config = Config::fromArray( + $this->values(['assets' => '/assets', 'templates' => '/tpl']), + '/project' + ); + + $copy = $config->withOutputDir('/elsewhere'); + + // Templates are input: a redirected run must read the same files or it + // is not the same run. Assets are output, and a redirected run wants + // everything it produces in one place. + $this->assertSame('/tpl', $copy->templatesDir()); + $this->assertSame('/elsewhere', $copy->assetsDir()); + } + /** * A root that already ends in a separator must not produce a doubled one. */ @@ -170,6 +193,20 @@ public function testSourceUrlNormalizesBackslashes(): void ); } + public function testTemplatesDefaultsToEmptyWhenAbsent(): void + { + $config = Config::fromArray($this->values(), '/project'); + + $this->assertSame('', $config->templatesDir()); + } + + public function testTemplatesIsResolvedRelativeToTheConfigurationFile(): void + { + $config = Config::fromArray($this->values(['templates' => 'docs/tpl']), '/project'); + + $this->assertSame('/project/docs/tpl', $config->templatesDir()); + } + public function testTrailingSeparatorsAreNormalized(): void { $config = new Config( diff --git a/tests/Unit/ExceptionsTest.php b/tests/Unit/ExceptionsTest.php index 550bbc4..179417a 100644 --- a/tests/Unit/ExceptionsTest.php +++ b/tests/Unit/ExceptionsTest.php @@ -22,8 +22,11 @@ use Phalcon\Quill\Exceptions\MissingConfigurationKey; use Phalcon\Quill\Exceptions\MissingDependency; use Phalcon\Quill\Exceptions\MissingDocument; +use Phalcon\Quill\Exceptions\MissingTemplate; +use Phalcon\Quill\Exceptions\MissingTemplateDirectory; use Phalcon\Quill\Exceptions\UnknownFormat; use Phalcon\Quill\Exceptions\UnknownLanguage; +use Phalcon\Quill\Exceptions\UnknownPlaceholder; use Phalcon\Quill\Exceptions\WriteFailed; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -112,6 +115,41 @@ public function testAMissingDocumentNamesTheFile(): void ); } + /** + * Silently ignoring the key would produce a successful run that applied no + * override, so the message has to offer both ways out rather than only + * report the fault. + */ + public function testAMissingTemplateDirectoryOffersBothWaysOut(): void + { + $exception = new MissingTemplateDirectory('/project/docs/tpl'); + + $this->assertSame( + "The configured templates directory '/project/docs/tpl' is not a" + . ' directory. Remove the `templates` key to use the shipped' + . ' templates, or correct the path - a run that silently ignored' + . ' it would apply no override and still report success.', + $exception->getMessage() + ); + } + + public function testAMissingTemplateListsEveryPathItTried(): void + { + $exception = new MissingTemplate( + 'class', + ['/project/tpl/markdown/class.tpl', '/vendor/quill/resources/templates/markdown/class.tpl'] + ); + + $this->assertSame( + "The template 'class' was not found. Looked in:" + . ' /project/tpl/markdown/class.tpl,' + . ' /vendor/quill/resources/templates/markdown/class.tpl.' + . ' The shipped set ships with quill, so a copy that cannot find' + . ' it is incomplete rather than misconfigured.', + $exception->getMessage() + ); + } + public function testAnIncompatibleDocumentReportsBothVersions(): void { $exception = new IncompatibleDocument('/project/model.json', 6, 7); @@ -156,6 +194,22 @@ public function testAnUnknownLanguageListsTheOnesThatWork(): void ); } + /** + * Every unsupplied token at once: fixing them one run at a time is the + * difference between one edit and five. + */ + public function testAnUnknownPlaceholderNamesEveryTokenNothingSupplies(): void + { + $exception = new UnknownPlaceholder('markdown/class', ['badge', 'title']); + + $this->assertSame( + "The template 'markdown/class' uses {{badge}}, {{title}}, which" + . ' nothing supplies. Placeholders a template does not use are' + . ' ignored; ones it invents are not.', + $exception->getMessage() + ); + } + public function testAWriteFailureBlamesThePermissionsRatherThanTheUser(): void { $exception = new WriteFailed('/root/out.md'); @@ -182,8 +236,11 @@ public function testEveryExceptionIsCatchableAsOneAndIsARuntimeFailure(): void new MissingConfigurationKey('branch'), new MissingDependency('a/b', 'Something'), new MissingDocument('/a'), + new MissingTemplate('class', ['/a']), + new MissingTemplateDirectory('/a'), new UnknownFormat('pdf', ['json']), new UnknownLanguage('cobol', ['php']), + new UnknownPlaceholder('markdown/class', ['title']), new WriteFailed('/a'), ]; diff --git a/tests/Unit/Formatter/GoldenPageTest.php b/tests/Unit/Formatter/GoldenPageTest.php new file mode 100644 index 0000000..43e58a2 --- /dev/null +++ b/tests/Unit/Formatter/GoldenPageTest.php @@ -0,0 +1,238 @@ + + * + * 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\Formatter; + +use Phalcon\Quill\Config; +use Phalcon\Quill\Formatter\MarkdownFormatter; +use Phalcon\Quill\Model\ClassDefinition; +use Phalcon\Quill\Model\ClassDefinitionCollection; +use Phalcon\Quill\Model\ConstantDefinition; +use Phalcon\Quill\Model\ConstantDefinitionCollection; +use Phalcon\Quill\Model\Imports; +use Phalcon\Quill\Model\Location; +use Phalcon\Quill\Model\Members; +use Phalcon\Quill\Model\MethodDefinition; +use Phalcon\Quill\Model\MethodDefinitionCollection; +use Phalcon\Quill\Model\ParameterDefinition; +use Phalcon\Quill\Model\ParameterDefinitionCollection; +use Phalcon\Quill\Model\PropertyDefinition; +use Phalcon\Quill\Model\PropertyDefinitionCollection; +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 dirname; +use function file_get_contents; +use function file_put_contents; +use function getenv; +use function is_file; + +/** + * Byte equality against a committed page. + * + * The expectation was generated from the formatter as it stood before the + * markup moved into templates, which is what makes it evidence rather than a + * restatement. It then pins the whitespace convention: a template that gains + * or loses a newline changes what mkdocs renders, and nothing else in the + * suite would notice. + * + * Regenerate deliberately, never to make a red test green: + * + * docker exec -e UPDATE_GOLDEN=1 quill-8.1 \ + * vendor/bin/phpunit -c resources/phpunit.xml.dist --filter GoldenPageTest + * + * Then read the diff. An intentional markup change shows up there; an + * accidental one shows up there too, which is the point. + */ +final class GoldenPageTest extends TestCase +{ + public function testThePageMatchesTheCommittedExpectation(): void + { + $documents = (new MarkdownFormatter())->format( + $this->registry(), + $this->config(), + new Selection('', '') + ); + + $page = $documents['phalcon_sample'] ?? self::fail('phalcon_sample page missing'); + $path = dirname(__DIR__, 2) . '/Fixtures/golden/phalcon_sample.md'; + + if (getenv('UPDATE_GOLDEN') !== false) { + file_put_contents($path, $page); + } + + if (!is_file($path)) { + self::fail('Golden file missing. Generate it with UPDATE_GOLDEN=1.'); + } + + $this->assertSame((string) file_get_contents($path), $page); + } + + private function config(): Config + { + return new Config( + 'zephir', + '/sources', + '/unused', + 'phalcon/cphalcon', + '5.0.x', + 'phalcon', + 'zep', + 'Phalcon' + ); + } + + /** + * Five declarations chosen to reach every branch of the page layout: an + * abstract class carrying the members, a final class that extends it, the + * interface it implements, the trait it uses, and a plain class for the + * default badge. Between them they exercise all five badges, the + * inheritance tree with an ancestor and with a child, the implements + * annotation, both inline lists, every member section, and each of the + * conditional slots - a class with no description, a constant with none, a + * property with no default, a method with no return type and a method with + * no description. + */ + private function registry(): Registry + { + $base = new ClassDefinition( + new Location('Phalcon\\Sample\\Base', 'Phalcon\\Sample', 'Sample/Base.zep'), + Structure::classType(true, false), + 'The abstract root.', + new Imports([], []), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection([ + new ConstantDefinition('LIMIT', '10', 'int', 'How many.'), + new ConstantDefinition('MARKER', null, 'string', ''), + ]), + new PropertyDefinitionCollection([ + new PropertyDefinition('label', 'protected', false, 'null', 'string|null', 'The label.', []), + new PropertyDefinition('secret', 'private', false, null, 'mixed', 'Hidden.', []), + new PropertyDefinition('store', 'public', false, null, 'array', '', []), + ]), + new MethodDefinitionCollection([ + new MethodDefinition( + 'describe', + ['public'], + 'public', + new ParameterDefinitionCollection([ + new ParameterDefinition('text', 'string', "'none'"), + new ParameterDefinition('depth', 'int', '0'), + ]), + 'string', + 'Describes the subject.' + ), + new MethodDefinition( + 'conceal', + ['protected'], + 'protected', + new ParameterDefinitionCollection([ + new ParameterDefinition('flag', 'bool', null), + ]), + null, + '' + ), + new MethodDefinition( + 'internal', + ['private'], + 'private', + new ParameterDefinitionCollection([]), + 'void', + 'Never rendered.' + ), + ]) + ) + ); + + $child = new ClassDefinition( + new Location('Phalcon\\Sample\\Child', 'Phalcon\\Sample', 'Sample/Child.zep'), + Structure::classType(false, true), + 'The concrete leaf.', + new Imports( + ['Phalcon\\Sample\\Contract', 'Phalcon\\Sample\\Base'], + ['Base' => 'Phalcon\\Sample\\Base', 'Contract' => 'Phalcon\\Sample\\Contract'] + ), + new Relations(['Base'], ['Contract'], ['Helper']), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection() + ) + ); + + $contract = new ClassDefinition( + new Location('Phalcon\\Sample\\Contract', 'Phalcon\\Sample', 'Sample/Contract.zep'), + Structure::interface(), + 'What the leaf promises.', + new Imports([], []), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection([ + new MethodDefinition( + 'describe', + ['public'], + 'public', + new ParameterDefinitionCollection([ + new ParameterDefinition('text', 'string', null), + ]), + 'string', + 'Describes the subject.' + ), + ]) + ) + ); + + $helper = new ClassDefinition( + new Location('Phalcon\\Sample\\Helper', 'Phalcon\\Sample', 'Sample/Helper.zep'), + Structure::trait(), + 'Shared behavior.', + new Imports([], []), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection() + ) + ); + + $plain = new ClassDefinition( + new Location('Phalcon\\Sample\\Plain', 'Phalcon\\Sample', 'Sample/Plain.zep'), + Structure::classType(false, false), + '', + new Imports([], []), + new Relations([], [], []), + new Members( + new ConstantDefinitionCollection(), + new PropertyDefinitionCollection(), + new MethodDefinitionCollection() + ) + ); + + return new Registry( + ClassDefinitionCollection::fromDefinitions([ + $base, + $child, + $contract, + $helper, + $plain, + ]), + 'Phalcon' + ); + } +} diff --git a/tests/Unit/Formatter/Markdown/MarkupContractTest.php b/tests/Unit/Formatter/Markdown/MarkupContractTest.php new file mode 100644 index 0000000..844b522 --- /dev/null +++ b/tests/Unit/Formatter/Markdown/MarkupContractTest.php @@ -0,0 +1,151 @@ + + * + * 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\Formatter\Markdown; + +use Phalcon\Quill\Formatter\Markdown\Classes; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +use function dirname; +use function file_get_contents; +use function glob; +use function implode; +use function preg_match_all; +use function preg_replace; +use function preg_split; +use function str_starts_with; +use function trim; + +/** + * The rendering contract, which after templating lives in three places: the + * templates that emit the class names, api.css that styles them, and the site + * theme supplying the --api-* palette. Distance between the first two is nil, + * so a drift is trivial to fix - the failure is that nothing tells you it + * drifted. badge--trait and .api-used-by have both already cost follow-up work + * that way. + * + * The reverse direction is deliberately not asserted. Selectors api.css + * carries that no template emits would flag dead CSS, but the file also holds + * --api-* custom properties and structural selectors that never appear as + * template tokens, so the check would be more noise than signal. + */ +final class MarkupContractTest extends TestCase +{ + /** + * The two declared names that are prefixes rather than complete classes, + * and everything the formatter appends to them. Without these, the first + * assertion would wave a new `badge--enum` through on the prefix alone and + * nothing would ask api.css for a rule. + * + * @var array> + */ + private const PREFIX_VARIANTS = [ + 'badge--' => ['abstract', 'class', 'final', 'interface', 'trait'], + 'vis-' => ['protected', 'public'], + ]; + + public function testEveryClassNameInATemplateIsDeclared(): void + { + $declared = $this->declared(); + + foreach ($this->tokens() as $token => $file) { + $known = false; + foreach ($declared as $name) { + if ($token === $name || str_starts_with($token, $name)) { + $known = true; + + break; + } + } + + $this->assertTrue( + $known, + "'" . $token . "' in " . $file . ' is not declared in Classes.' + . ' Add the constant and a rule in api.css, or correct the template.' + ); + } + } + + public function testEveryDeclaredNameHasASelector(): void + { + $css = (string) file_get_contents(dirname(__DIR__, 4) . '/resources/api.css'); + + foreach ($this->declared() as $name) { + $variants = self::PREFIX_VARIANTS[$name] ?? ['']; + + foreach ($variants as $variant) { + $this->assertStringContainsString( + '.' . $name . $variant, + $css, + "Classes declares '" . $name . $variant . "' and api.css does not style it." + ); + } + } + } + + /** + * @return list + */ + private function declared(): array + { + /** @var list $values */ + $values = (new ReflectionClass(Classes::class))->getConstants(); + + return $values; + } + + /** + * Every class name the shipped templates emit, mapped to the file it came + * from so a failure names it. + * + * Placeholders are stripped before the attributes are read, which turns + * `badge--{{structure}}` into `badge--` and `vis-{{visibility}}` into + * `vis-` - exactly the prefixes Classes declares. + * + * @return array + */ + private function tokens(): array + { + $directory = dirname(__DIR__, 4) . '/resources/templates/markdown'; + $tokens = []; + + foreach (glob($directory . '/*.tpl') ?: [] as $path) { + $body = (string) preg_replace( + '/\{\{[a-zA-Z][a-zA-Z0-9]*\}\}/', + '', + (string) file_get_contents($path) + ); + + preg_match_all('/class="([^"]*)"/', $body, $attributes); + foreach ($attributes[1] as $attribute) { + foreach (preg_split('/\s+/', trim($attribute)) ?: [] as $token) { + if ($token !== '') { + $tokens[$token] = $path; + } + } + } + + // The `{ .name }` attribute lists mkdocs reads, which is how the + // source button and both inline lists carry their class. + preg_match_all('/\{\s*\.([A-Za-z0-9_-]+)\s*\}/', $body, $lists); + foreach ($lists[1] as $token) { + $tokens[$token] = $path; + } + } + + $this->assertNotSame([], $tokens, 'No templates were read: ' . implode(', ', [$directory])); + + return $tokens; + } +} diff --git a/tests/Unit/Reader/ZephirReaderTest.php b/tests/Unit/Reader/ZephirReaderTest.php index aa6049c..4a635ce 100644 --- a/tests/Unit/Reader/ZephirReaderTest.php +++ b/tests/Unit/Reader/ZephirReaderTest.php @@ -90,6 +90,26 @@ public function testANullDefaultMakesTheParameterNullable(): void $this->assertSame('int', $plain['count']); } + /** + * Zephir writes no union, so a declared type with a null default is the + * only one it can express - and it is the same thing the PHP twin spells + * `?string`. Read as the bare declaration, the two implementations would + * report a parity difference that is not one. + */ + public function testATypedPropertyWithANullDefaultIsNullable(): void + { + $properties = []; + foreach ($this->shapes()->members->properties->all() as $property) { + $properties[$property->name] = $property; + } + + $this->assertSame('string|null', $properties['title']->varType); + + // The same declaration without the null default stays bare, so it is + // the default doing the widening rather than the type being optional. + $this->assertSame('array', $properties['registry']->varType); + } + public function testCapturesPrivateMembers(): void { $class = $this->sample(); diff --git a/tests/Unit/Template/TemplatesTest.php b/tests/Unit/Template/TemplatesTest.php new file mode 100644 index 0000000..202eb2f --- /dev/null +++ b/tests/Unit/Template/TemplatesTest.php @@ -0,0 +1,209 @@ + + * + * 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\Template; + +use Phalcon\Quill\Exceptions\MissingTemplate; +use Phalcon\Quill\Exceptions\MissingTemplateDirectory; +use Phalcon\Quill\Exceptions\UnknownPlaceholder; +use Phalcon\Quill\Template\Templates; +use PHPUnit\Framework\TestCase; + +use function dirname; +use function implode; + +/** + * Resolution order, substitution and the three ways a template set can be + * wrong. + * + * The fixtures under `tests/Fixtures/templates` stand in for a consumer's own + * directory; the shipped set under `resources/templates` is the fallback. + */ +final class TemplatesTest extends TestCase +{ + public function testACustomFileWinsForThatNameOnly(): void + { + $custom = Templates::for('markdown', $this->fixtures()); + $shipped = Templates::for('markdown', ''); + + // The same name, two bodies: the custom directory is consulted first + // rather than merged into the shipped one. + $this->assertStringContainsString( + 'CUSTOM', + $custom->render('class', $this->classValues()) + ); + $this->assertStringNotContainsString( + 'CUSTOM', + $shipped->render('class', $this->classValues()) + ); + + // A name the custom directory does not carry still resolves, so + // overriding one template is not vendoring the rest. + $this->assertStringContainsString( + 'All classes are prefixed with', + $custom->render('page', ['classes' => '', 'namespace' => 'Phalcon']) + ); + } + + public function testAFileOutsideTheFormatDirectoryIsReported(): void + { + $warnings = Templates::unrecognized('markdown', $this->fixtures()); + + $joined = implode("\n", $warnings); + + $this->assertStringContainsString("sits outside 'markdown/'", $joined); + $this->assertStringContainsString('misplaced.tpl', $joined); + } + + /** + * The known-name set is the shipped directory's own listing, so a format + * that ships none has no set to be outside of and the call is inert. + */ + public function testAFormatWithNoShippedTemplatesIsInert(): void + { + $this->assertSame([], Templates::unrecognized('json', $this->fixtures())); + } + + public function testAnUnmatchedTemplateNameIsReported(): void + { + $warnings = Templates::unrecognized('markdown', $this->fixtures()); + + $this->assertStringContainsString('constants-row.tpl', $warnings[0]); + $this->assertStringContainsString("Did you mean 'constant-row.tpl'?", $warnings[0]); + } + + public function testAnUnsuppliedPlaceholderThrowsNamingEveryToken(): void + { + $templates = Templates::for('markdown', $this->fixtures()); + + $this->expectException(UnknownPlaceholder::class); + $this->expectExceptionMessage('{{alpha}}, {{beta}}'); + + $templates->render('pair', []); + } + + public function testARepeatedPlaceholderIsSubstitutedEverywhere(): void + { + $templates = Templates::for('markdown', $this->fixtures()); + + $this->assertSame( + 'twice and twice again', + $templates->render('repeat', ['word' => 'twice']) + ); + } + + public function testAShippedTemplateIsRendered(): void + { + // No custom directory, so the only place this can come from is the set + // that ships with quill. + $templates = Templates::for('markdown', ''); + + $this->assertStringContainsString( + 'Sample\\Base', + $templates->render('class', $this->classValues()) + ); + } + + public function testASuppliedValueNoPlaceholderUsesIsIgnored(): void + { + $templates = Templates::for('markdown', $this->fixtures()); + + // One-way strictness: a consumer's template may use fewer slots than + // it is handed without that being an error. + $this->assertSame( + 'twice and twice again', + $templates->render('repeat', ['word' => 'twice', 'unused' => 'ignored']) + ); + } + + public function testATemplatesPathThatIsNotADirectoryThrows(): void + { + $this->expectException(MissingTemplateDirectory::class); + $this->expectExceptionMessage('is not a directory'); + + Templates::for('markdown', $this->fixtures() . '/markdown/class.tpl'); + } + + public function testATemplateWithNoPlaceholdersRendersVerbatim(): void + { + $templates = Templates::for('markdown', $this->fixtures()); + + $this->assertSame( + 'nothing to substitute', + $templates->render('verbatim', []) + ); + } + + public function testAValueContainingAPlaceholderIsNotRescanned(): void + { + $templates = Templates::for('markdown', $this->fixtures()); + + // strtr makes a single pass, so a description that happens to contain + // {{title}} is text, not an instruction. + $this->assertSame( + 'literal {{title}} here', + $templates->render('echo', ['value' => '{{title}}']) + ); + } + + public function testExactlyOneTrailingNewlineIsStripped(): void + { + // The file ends "...

\n\n" - a blank line, which is how a template + // whose output must end in a newline is written. One newline goes, one + // survives. + $templates = Templates::for('markdown', $this->fixtures()); + + $this->assertSame("

x

\n", $templates->render('trailing', [])); + } + + public function testMissingInBothThrowsNamingEveryPathItTried(): void + { + $templates = Templates::for('markdown', $this->fixtures()); + + $this->expectException(MissingTemplate::class); + $this->expectExceptionMessage('tests/Fixtures/templates/markdown/nowhere.tpl'); + + $templates->render('nowhere', []); + } + + /** + * Every slot the shipped class template will ever carry. Supplied whole so + * the two tests that render it keep passing as the real markup replaces the + * stub - a value no template uses is ignored, which is what makes a + * superset safe. + * + * @return array + */ + private function classValues(): array + { + return [ + 'badge' => 'Class', + 'constants' => '', + 'description' => '', + 'methods' => '', + 'properties' => '', + 'sourceUrl' => 'https://example.com/Base.zep', + 'structure' => 'class', + 'summary' => '', + 'title' => 'Sample\\Base', + 'tree' => '', + 'usedBy' => '', + 'uses' => '', + ]; + } + + private function fixtures(): string + { + return dirname(__DIR__, 2) . '/Fixtures/templates'; + } +}