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
49 changes: 49 additions & 0 deletions .claude/plans/route-list/001-route-list-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Task 001: RouteListCommand Basic Table Output

**Status**: completed
**Depends on**: none
**Retry count**: 0

## Description
Create the `RouteListCommand` that injects `RouteCollection` and displays all registered routes in an aligned table with columns: METHOD, PATH, ACTION, MIDDLEWARE. Routes are sorted by path then method. Shows a helpful message when no routes exist.

## Context
- Related files:
- `packages/routing/src/Commands/RouteListCommand.php` (new)
- `packages/routing/tests/Commands/RouteListCommandTest.php` (new — this directory does not exist yet, create it)
- `packages/core/src/Commands/ModuleListCommand.php` (pattern to follow — note: empty state handling is a divergence; ModuleListCommand does not handle empty collections)
- `packages/core/tests/Command/ModuleListCommandTest.php` (test pattern to follow)
- `packages/routing/src/RouteCollection.php` (injected dependency)
- `packages/routing/src/RouteDefinition.php` (route data)
- `packages/core/src/Command/CommandInterface.php` (interface to implement)
- `packages/core/src/Attributes/Command.php` (attribute to use)
- Patterns to follow:
- `ModuleListCommand` for table output with `str_pad()` alignment
- `ModuleListCommandTest` for test structure using `php://memory` stream
- Constructor property promotion, `declare(strict_types=1)`, no `final`, `readonly class`
- ACTION column uses short class name (`UserController::index`), not FQCN — strip namespace
- No separator line between header and data (consistent with `ModuleListCommand`)

## Requirements (Test Descriptions)
- [ ] `it has Command attribute with name route:list`
- [ ] `it has Command attribute with description Show all registered routes`
- [ ] `it implements CommandInterface`
- [ ] `it displays METHOD column header`
- [ ] `it displays PATH column header`
- [ ] `it displays ACTION column header`
- [ ] `it displays MIDDLEWARE column header`
- [ ] `it displays route method path and action for each route`
- [ ] `it displays middleware as short class names`
- [ ] `it sorts routes by path then by method`
- [ ] `it displays No routes registered when collection is empty`
- [ ] `it displays empty middleware column when route has no middleware`
- [ ] `it formats output with aligned columns`
- [ ] `it returns exit code 0`

## Acceptance Criteria
- All requirements have passing tests
- Code follows code standards (phpcs/php-cs-fixer clean)
- `RouteListCommand` lives in `Marko\Routing\Commands` namespace

## Implementation Notes
(Left blank - filled in by programmer during implementation)
36 changes: 36 additions & 0 deletions .claude/plans/route-list/002-method-path-filtering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Task 002: Method and Path Filtering

**Status**: completed
**Depends on**: 001
**Retry count**: 0

## Description
Add `--method` and `--path` filter options to `RouteListCommand`. Method filter is case-insensitive and uses the existing `RouteCollection::byMethod()`. **Important:** `strtoupper()` must be applied to the `--method` option value BEFORE passing to `byMethod()`, because `byMethod()` uses strict `===` comparison and routes are stored with uppercase methods ('GET', 'POST', etc.). Path filter uses `str_contains()` substring matching. Leading slash is stripped only from the filter value (route paths always start with `/`). Filters can be combined with AND logic. Shows a helpful message when filters match nothing.

## Context
- Related files:
- `packages/routing/src/Commands/RouteListCommand.php` (modify)
- `packages/routing/tests/Commands/RouteListCommandTest.php` (add tests)
- `packages/core/src/Command/Input.php` (has `hasOption()`/`getOption()` for parsing `--method=GET`)
- `packages/routing/src/RouteCollection.php` (has `byMethod()` already)
- Patterns to follow:
- `Input::getOption('method')` returns the value after `=`, or `null` if not present
- `Input::hasOption('method')` checks existence

## Requirements (Test Descriptions)
- [ ] `it filters routes by method when --method option is provided`
- [ ] `it filters routes by method case-insensitively`
- [ ] `it filters routes by path substring when --path option is provided`
- [ ] `it strips leading slash from path filter value before matching (route paths always start with /)`
- [ ] `it combines method and path filters`
- [ ] `it displays No routes match the given filters when filters match nothing`
- [ ] `it shows all routes when no filters are provided`

## Acceptance Criteria
- All requirements have passing tests
- Filtering works with both `--method=GET` and `--path=users` syntax
- Filters compose cleanly (AND logic)
- Code follows code standards

## Implementation Notes
(Left blank - filled in by programmer during implementation)
57 changes: 57 additions & 0 deletions .claude/plans/route-list/_devils_advocate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Devil's Advocate Review: route-list

## Critical (Must fix before building)

No critical issues found. The plan is well-scoped and aligns correctly with existing patterns.

## Important (Should fix before building)

### 1. `byMethod()` does strict equality -- case-insensitivity must happen BEFORE calling it (Task 002)

The plan says to use `strtoupper()` for case-insensitivity, which is correct. However, the task description says "Method filter is case-insensitive and uses the existing `RouteCollection::byMethod()`" without explicitly noting that `strtoupper()` must be applied to the user input BEFORE passing to `byMethod()`, since `byMethod()` does a strict `===` comparison (`$route->method === $method`). Routes are stored as uppercase ('GET', 'POST', etc.) per the attribute classes.

This is implied but should be explicit in the task requirements to prevent the worker from passing the raw user input directly to `byMethod()`.

**Fix:** Add explicit note in task 002 that `strtoupper()` must be applied to the `--method` option value before passing to `byMethod()`.

### 2. Test file path inconsistency with existing routing test structure (Tasks 001, 002)

The plan places tests at `packages/routing/tests/Commands/RouteListCommandTest.php`. Looking at the existing routing test files, tests are NOT organized in subdirectories matching `src/` -- they are flat in `tests/` (e.g., `tests/RouteCollectionTest.php`, `tests/RouteDefinitionTest.php`). The only subdirectories are `Attributes/`, `Exceptions/`, `Http/`, `Integration/`, `Middleware/`, and `Fixtures/`.

Meanwhile, the pattern being followed (`ModuleListCommand`) has its test at `packages/core/tests/Command/ModuleListCommandTest.php` -- note `Command/` (singular), not `Commands/` (plural), while the source is in `Commands/` (plural).

The routing package does not have a `Commands/` test directory. Either path convention works, but the task should be explicit about which to use.

**Fix:** Keep `tests/Commands/` to match the source namespace `src/Commands/`. This is fine -- the routing package just hasn't had command tests before. Note this in task 001 context.

### 3. Missing test for routes with no middleware (Task 001)

The requirements test middleware display as "short class names" but don't explicitly cover the case where a route has an empty middleware array (`middleware = []` is the default per `RouteDefinition`). The column should show nothing (empty string) rather than crashing.

**Fix:** Add requirement to task 001.

### 4. Missing test for the `--path` filter's leading slash handling (Task 002)

The requirement says "it strips leading slash from path filter before matching" but needs clarification: should it strip the leading slash from the filter input, from the route path, or both? Route paths in `RouteDefinition` start with `/` (e.g., `/users/{id}`). If the user passes `--path=/users`, stripping the leading slash from the filter gives `users`, and then `str_contains('/users/{id}', 'users')` works. But if the user passes `--path=users`, it also works without stripping. The stripping only matters for the filter input, not the route path.

**Fix:** Clarify in task 002 that the leading slash is stripped only from the filter value, not from route paths. Route paths always start with `/`.

## Minor (Nice to address)

### 1. ModuleListCommand has no empty-state handling

The plan references `ModuleListCommand` as the pattern to follow, but `ModuleListCommand` does NOT handle the empty collection case -- it just prints headers with no rows. Task 001 correctly adds "No routes registered." for the empty case, which is good, but the worker should know this is a divergence from the exact `ModuleListCommand` pattern.

### 2. Consider `readonly class` for RouteListCommand

Per code standards, if all constructor-promoted properties are readonly, use `readonly class`. Since `RouteListCommand` will only have `private RouteCollection $routeCollection`, the class should likely be `readonly class RouteListCommand`. The worker should be aware of this standard.

### 3. Action column format not specified

The plan says "ACTION (controller::method)" but doesn't specify whether to show the FQCN or just the short class name. For consistency with the middleware column (short class names), it might make sense to use short class names for the controller too. However, using FQCN provides more useful information for debugging. This is a design choice the plan should make explicitly.

## Questions for the Team

1. **Action column format**: Should the ACTION column show the full namespace (`App\Http\Controllers\UserController::index`) or short class name (`UserController::index`)? Full namespace is more useful for debugging but makes the table wider.

2. **Separator line**: Should there be a separator line (e.g., dashes) between the header and data rows? `ModuleListCommand` doesn't have one, but route tables in other frameworks (Laravel's `route:list`, for example) typically do.
63 changes: 63 additions & 0 deletions .claude/plans/route-list/_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Plan: route:list CLI Command

## Created
2026-04-12

## Status
completed

## Objective
Add a `route:list` CLI command to the `marko/routing` package that displays all registered routes in a formatted table, with filtering options for method and path.

## Related Issues
none

## Scope

### In Scope
- `RouteListCommand` class in `packages/routing/src/Commands/`
- Table output: METHOD, PATH, ACTION (controller::method), MIDDLEWARE
- `--method=GET` filter (case-insensitive)
- `--path=users` filter (substring match, leading slash optional)
- Sorted output (by path, then method)
- Empty state message when no routes match
- Routes sorted alphabetically by path for scanability

### Out of Scope
- Disabled route display (omitted = not routable, intuitive)
- Override/Preference metadata display (no plumbing exists, not needed for v1)
- Route conflict detection (already caught at boot time with loud errors)
- `--verbose` mode
- `route:cache` or `route:clear` (future commands)

## Success Criteria
- [ ] `marko route:list` displays all active routes in aligned columns
- [ ] `--method=GET` filters to only GET routes
- [ ] `--path=users` filters to routes containing "users" in path
- [ ] Filters can be combined
- [ ] Empty collection shows helpful message
- [ ] All tests passing
- [ ] Code follows project standards

## Task Overview
| Task | Description | Depends On | Status |
|------|-------------|------------|--------|
| 001 | RouteListCommand basic table output | - | pending |
| 002 | Method and path filtering | 001 | pending |

## Architecture Notes
- Command lives in `packages/routing/src/Commands/RouteListCommand.php` (routing package owns its own commands)
- Inject `RouteCollection` — already available in container after routing boots
- Follow `ModuleListCommand` pattern: `str_pad()` alignment, `Output::writeLine()`, `php://memory` stream in tests
- Use `#[Command(name: 'route:list', description: 'Show all registered routes')]`
- Sort routes by path then method for consistent, scannable output
- `--method` filter uses `RouteCollection::byMethod()` (already exists), case-insensitive via `strtoupper()`
- `--path` filter uses `str_contains()` substring match, strips leading slash for convenience
- Middleware displayed as comma-separated short class names (last segment of FQCN)

## Risks & Mitigations
- **RouteCollection not in container**: Command runs after boot, so routes are already discovered. If somehow empty, show "No routes registered." message.
- **Long middleware lists breaking alignment**: Use short class names (strip namespace) to keep columns manageable.
- **ACTION column format**: Use short class name (`UserController::index`), not FQCN. Future `--verbose` flag can show full namespaces.
- **No separator line**: Consistent with `ModuleListCommand` pattern. No header/data separator row.
- **readonly class**: `RouteListCommand` should be `readonly` — single immutable dependency.
29 changes: 29 additions & 0 deletions docs/src/content/docs/packages/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,35 @@ Resolution: Use #[Preference] to extend one controller,
or use #[DisableRoute] to remove one route.
```

## CLI

Requires [`marko/cli`](/docs/packages/cli/) for the `marko` binary.

### Listing Routes

See all registered routes:

```bash
marko route:list
```

```
METHOD PATH ACTION MIDDLEWARE
GET / HelloController::index
GET /blog PostController::index
GET /blog/{id} PostController::show
GET /products ProductController::index
GET /products/{id} ProductController::show
```

Filter by HTTP method or path:

```bash
marko route:list --method=POST
marko route:list --path=products
marko route:list --method=GET --path=blog
```

## API Reference

### Route Attributes
Expand Down
127 changes: 127 additions & 0 deletions packages/routing/src/Commands/RouteListCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace Marko\Routing\Commands;

use Marko\Core\Attributes\Command;
use Marko\Core\Command\CommandInterface;
use Marko\Core\Command\Input;
use Marko\Core\Command\Output;
use Marko\Routing\RouteCollection;
use Marko\Routing\RouteDefinition;

/** @noinspection PhpUnused */
#[Command(name: 'route:list', description: 'Show all registered routes')]
readonly class RouteListCommand implements CommandInterface
{
public function __construct(
private RouteCollection $routes,
) {}

public function execute(
Input $input,
Output $output,
): int {
$hasFilters = $input->hasOption('method') || $input->hasOption('path');
$routes = $this->applyFilters($input);

if ($routes === []) {
$message = $hasFilters ? 'No routes match the given filters' : 'No routes registered';
$output->writeLine($message);

return 0;
}

usort($routes, function (RouteDefinition $a, RouteDefinition $b): int {
$pathCmp = strcmp($a->path, $b->path);

if ($pathCmp !== 0) {
return $pathCmp;
}

return strcmp($a->method, $b->method);
});

$methodWidth = strlen('METHOD');
$pathWidth = strlen('PATH');
$actionWidth = strlen('ACTION');

foreach ($routes as $route) {
$action = $this->shortAction($route);
$methodWidth = max($methodWidth, strlen($route->method));
$pathWidth = max($pathWidth, strlen($route->path));
$actionWidth = max($actionWidth, strlen($action));
}

$methodWidth += 2;
$pathWidth += 2;
$actionWidth += 2;

$output->writeLine(
str_pad('METHOD', $methodWidth) .
str_pad('PATH', $pathWidth) .
str_pad('ACTION', $actionWidth) .
'MIDDLEWARE',
);

foreach ($routes as $route) {
$action = $this->shortAction($route);
$middleware = $this->shortMiddleware($route->middleware);
$output->writeLine(
str_pad($route->method, $methodWidth) .
str_pad($route->path, $pathWidth) .
str_pad($action, $actionWidth) .
$middleware,
);
}

return 0;
}

/**
* @return array<int, RouteDefinition>
*/
private function applyFilters(Input $input): array
{
$routes = $this->routes->all();

if ($input->hasOption('method')) {
$method = strtoupper((string) $input->getOption('method'));
$routes = array_values(
array_filter($routes, fn (RouteDefinition $r): bool => $r->method === $method),
);
}

if ($input->hasOption('path')) {
$path = ltrim((string) $input->getOption('path'), '/');
$routes = array_values(
array_filter($routes, fn (RouteDefinition $r): bool => str_contains($r->path, $path)),
);
}

return $routes;
}

private function shortAction(RouteDefinition $route): string
{
$parts = explode('\\', $route->controller);
$shortClass = end($parts);

return $shortClass . '::' . $route->action;
}

/**
* @param array<int, string> $middleware
*/
private function shortMiddleware(array $middleware): string
{
$short = array_map(function (string $class): string {
$parts = explode('\\', $class);

return end($parts);
}, $middleware);

return implode(', ', $short);
}
}
Loading