Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 28 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -55,25 +52,24 @@ the way the Zephir one is.
| `--config=<path>` | explicit path to `quill.php`, default `./quill.php` |
| `--output=<dir>` | destination override for one run; assets follow the documents |
| `--format=<name>` | `markdown` (default) or `json` |
| `--namespace=<ns>` | 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 |
| `<filter>` | 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
<?php

return [
'language' => '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',
Expand All @@ -92,24 +88,17 @@ 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/* <site>/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/* <site>/docs/` lands the pages and the stylesheet where each belongs.

## What `generate` writes

- one page per top-level namespace segment, named `<namespace>_<segment>.<ext>`
- 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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
40 changes: 32 additions & 8 deletions bin/quill
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -50,6 +51,7 @@ $usage = $banner . <<<'USAGE'

Usage:
quill generate [filter] [--config=<path>] [--output=<dir>] [--format=<name>]
[--namespace=<ns>]
quill parity <left.json> <right.json>
quill docblocks <left.json> <right.json> <out.csv>

Expand All @@ -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'] ?? [];
Expand All @@ -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<string> $argv
*
* @return list<string>
*/
$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);
Expand All @@ -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);
Expand All @@ -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=')) {
Expand All @@ -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);

Expand All @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions resources/api.css
Original file line number Diff line number Diff line change
@@ -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
Expand Down
39 changes: 33 additions & 6 deletions src/Cli/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
}
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/Contracts/Formatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string> document key => rendered document
*/
public function format(Registry $registry, Config $config, string $filter = ''): array;
public function format(Registry $registry, Config $config, Selection $selection): array;
}
Loading