Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,13 @@ when a selector changes. The dynamic wrapper's scalar attributes become
strings, while objects and arrays enter the inner view's data. Literal `:is`
names navigate to components; variables and constants keep PHP navigation.

Tempest route attributes keep their built-in prefixes precomputed. Custom
route decorators are resolved once per attribute class within a request, in
method-then-class order. Their `decorate()` bodies are read in the declaring
file's namespace, including inherited and trait methods; recognized literal
prefix transformations supply the same URI to completion, navigation, and
parameter references without copying the route's declaration data.

## Tempest model property names

Tempest database property arguments use the shared resolver to read the model
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Tempest

- **Custom route decorators.** Route completion and navigation follow URI prefixes added by a project's own decorators. Contributed by @shuvroroy.
- **Queries built from model class names.** `query(Book::class)` keeps query results typed as `Book` objects through fluent calls. Contributed by @shuvroroy.
- **Container invocation arguments.** The named arguments `Container::invoke()` and the `invoke()` helper forward complete from the callable's parameters and go to them. Contributed by @shuvroroy.
- **Built-in view documentation.** Completion and hover explain Tempest's built-in components and directives, with links to the view specification. Contributed by @shuvroroy.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ ships, remove every row that names it.
| # | Item | Impact | Complexity |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
| TM8 | [The data `view()` passes completes and navigates](todo/tempest.md#tm8-the-data-view-passes-completes-and-navigates) | Low-Medium | Low-Medium |
| TM7 | [A project's own route decorators change the route's URI](todo/tempest.md#tm7-a-projects-own-route-decorators-change-the-routes-uri) | Low | Medium |

## PHPUnit & Pest

Expand Down
37 changes: 2 additions & 35 deletions docs/todo/tempest.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ templates already get completion, hover, go-to-definition, and
diagnostics through virtual PHP, the data a view is rendered with is
typed from the calls and View objects that render it, and component
tags, their attributes, and the paths passed to `view()` complete and
navigate. What is still missing is route URIs that a project's own
decorators change.
navigate. The remaining gap is completing and navigating the data
passed to a view at the call site.

Work out each behaviour from the framework's own source
(`tempest/view`, `tempest/router`, `tempest/database`,
Expand Down Expand Up @@ -44,36 +44,3 @@ template reads that the call does not pass yet, and navigate and hover
from a name to the variable, reusing the reverse of the lookup that
types the template (`src/tempest/view_data.rs`). A custom View object's
constructor arguments are ordinary parameters and keep their handling.

---

## TM7. A project's own route decorators change the route's URI

**Impact: Low · Complexity: Medium**

```php
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class Admin implements RouteDecorator
{
public function decorate(Route $route): Route
{
return new Prefix('/admin')->decorate($route);
}
}

#[Admin]
final class BookController
{
#[Get('/books/{book}')] // the route is /admin/books/{book}
public function show(Book $book): View { /* ... */ }
}
```

Route discovery applies every attribute that implements
`Tempest\Router\RouteDecorator`, method ones before class ones, but only
`Tempest\Router\Prefix` is read for the URI it adds. A project's own
decorator that rewrites the URI is skipped, so its routes are recorded
without the prefix, and a `uri()` literal that includes it finds no
route. Read a decorator whose `decorate()` hands the route to a `Prefix`
it builds from a literal, or assigns `$route->uri` from a literal joined
with the old URI, as that prefix.
3 changes: 2 additions & 1 deletion examples/tempest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ The `.view.php` files use the editor's PHP language-server connection.
In `BookController.php`, follow route placeholders to parameters and rename them
together with their URI arguments. Routes without a leading slash complete
as absolute paths. Custom route attributes and controller
prefixes work too.
prefixes work too. The custom `Admin` and `Versioned` decorators produce
`/admin/v1/books/{book}`; method decorators run before controller decorators.
- In `app/books.view.php`, complete directives and component tags, follow
`<x-card>` to its file, and complete or navigate `$book`'s members.
Follow `:title` and `:show-title` to their first variable reads in the
Expand Down
19 changes: 19 additions & 0 deletions examples/tempest/app/Admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App;

use Attribute;
use Tempest\Router\Prefix;
use Tempest\Router\Route;
use Tempest\Router\RouteDecorator;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class Admin implements RouteDecorator
{
public function decorate(Route $route): Route
{
return new Prefix('/admin')->decorate($route);
}
}
3 changes: 3 additions & 0 deletions examples/tempest/app/BookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use function Tempest\View\view;

#[Admin]
final class BookController
{
#[Get('/books')]
Expand All @@ -19,6 +20,8 @@ public function index(): View
}

// Try: complete inside {book}, or follow it to the parameter below.
// The method decorator runs first: /admin/v1/books/{book}.
#[Versioned]
#[ReadBook('books/{book}')]
public function show(string $book): string
{
Expand Down
4 changes: 2 additions & 2 deletions examples/tempest/app/RouteLinks.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public function links(): array
return [
// Try: complete book:, follow it to $book, or rename the parameter.
uri([BookController::class, 'show'], book: 'php'),
// Try: complete the URI string or follow it to show().
uri('/books/{book}', book: 'php'),
// Try: complete the decorated URI or follow it to show().
uri('/admin/v1/books/{book}', book: 'php'),
// Try: complete locale: or follow it to the invokable action's parameter.
uri(HomeController::class, locale: 'en'),
// Try: complete a controller name or follow it to __invoke().
Expand Down
20 changes: 20 additions & 0 deletions examples/tempest/app/Versioned.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App;

use Attribute;
use Tempest\Router\Route;
use Tempest\Router\RouteDecorator;

#[Attribute(Attribute::TARGET_METHOD)]
final class Versioned implements RouteDecorator
{
public function decorate(Route $route): Route
{
$route->uri = '/v1/' . $route->uri;

return $route;
}
}
4 changes: 2 additions & 2 deletions examples/tempest/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@
);
$container->singleton(Tempest\Router\UriGenerator::class, $generator);
if (new App\RouteLinks()->links() !== [
'https://books.example/books/php',
'https://books.example/books/php',
'https://books.example/admin/v1/books/php',
'https://books.example/admin/v1/books/php',
'https://books.example/api/home/en',
'https://books.example/api/home/en',
]) {
Expand Down
18 changes: 12 additions & 6 deletions src/symbol_map/extraction/tempest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::virtual_members::laravel::helpers::resolved_string_literal_including_
/// Keep route-shaped attributes during the class walk. Their types are
/// resolved later, when cross-file interfaces are available.
pub(super) fn record_route_attributes(class: &Class<'_>, ctx: &mut ExtractionCtx<'_>) {
use crate::tempest::routes::{RouteAttribute, RouteParameter, placeholders};
use crate::tempest::routes::{RouteAttribute, RouteDecorator, RouteParameter, placeholders};

if !ctx.tempest {
return;
Expand Down Expand Up @@ -62,7 +62,8 @@ pub(super) fn record_route_attributes(class: &Class<'_>, ctx: &mut ExtractionCtx
offset: parameter.variable.span.start.offset,
})
.collect();
let mut route_path = path.to_string();
let mut decorators = Vec::new();
let mut route_path = path.to_owned();
let mut route_placeholders: Vec<_> = placeholders(&ctx.content[start..end])
.into_iter()
.map(|(from, to)| (start as u32 + from as u32, start as u32 + to as u32))
Expand All @@ -73,10 +74,11 @@ pub(super) fn record_route_attributes(class: &Class<'_>, ctx: &mut ExtractionCtx
.chain(class.attribute_lists.iter())
.flat_map(|list| list.attributes.iter())
{
if !ctx
.resolved_name_at(decorator.name.span().start.offset)
.is_some_and(|name| name.eq_ignore_ascii_case("Tempest\\Router\\Prefix"))
{
let Some(name) = ctx.resolved_name_at(decorator.name.span().start.offset) else {
continue;
};
if !name.eq_ignore_ascii_case("Tempest\\Router\\Prefix") {
decorators.push(RouteDecorator::Custom(crate::atom::atom(name)));
continue;
}
let Some(value) = decorator.argument_list.as_ref().and_then(|args| {
Expand All @@ -98,6 +100,7 @@ pub(super) fn record_route_attributes(class: &Class<'_>, ctx: &mut ExtractionCtx
else {
continue 'attributes;
};
decorators.push(RouteDecorator::Prefix(crate::atom::atom(prefix)));
route_path = format!(
"{}/{}",
prefix.trim_end_matches('/'),
Expand All @@ -112,12 +115,15 @@ pub(super) fn record_route_attributes(class: &Class<'_>, ctx: &mut ExtractionCtx
if !route_path.starts_with('/') {
route_path.insert(0, '/');
}
let original_path = (path != route_path).then(|| path.into());
ctx.tempest_routes.push(RouteAttribute {
attribute: crate::atom::atom(attribute_name),
class: crate::atom::atom(class_name),
method: crate::atom::atom_bytes(method.name.value),
method_offset: method.name.span.start.offset,
path: route_path,
original_path,
decorators: decorators.into_boxed_slice(),
start: start as u32,
placeholders: route_placeholders,
parameters,
Expand Down
1 change: 1 addition & 0 deletions src/tempest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(crate) mod inference;
mod model_properties;
mod paths;
pub(crate) mod preprocessor;
mod route_decorators;
pub(crate) mod routes;
mod syntax;
mod view_data;
Expand Down
Loading
Loading