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
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,12 @@ two templates and incoming scope are unchanged, so typing in one view
re-evaluates only the calls that edit reaches. A stale indexing batch cannot
overwrite a lowering built with newer inferred data.

Named data arguments to `view()` reverse that same path lookup. Completion
reads the target template's source-backed variable uses, excluding local
bindings and data the call already supplies. Definition and hover use each
input's first source read, with the template's normal PHP type resolution.
This lookup reads only the selected template and retains no separate cache.

Component arguments are evaluated in the caller, while slot contents are
lowered into isolated closures. `tempest/component_context.rs` resolves slot
locals at the receiving component's outlet for that invocation. Component
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

- **View data arguments.** Named data passed to `view()` completes from the template's inputs, navigates to their first reads, and shows their types on hover. 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
2 changes: 0 additions & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ contributor even though it's short.

| # | 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 |
| | **Release 1.0.0 + IDE extensions** | | |

## Sprint 9 — Startup performance
Expand Down Expand Up @@ -157,7 +156,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
22 changes: 0 additions & 22 deletions docs/todo/tempest.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,6 @@ within the same impact tier.

---

## TM8. The data `view()` passes completes and navigates

**Impact: Low-Medium · Complexity: Low-Medium**

```php
return view('./books.view.php', books: $books, title: 'Books');
```

The named arguments `view()` passes become the template's variables,
and the template already types `$books` from them, but the call site
gets nothing back: completion inside the call offers only `params:`,
and go-to-definition and hover on `books:` find nothing. Laravel's
`view('books', ['books' => …])` and Yii's `render('index', ['form' => …])`
already do this (a **View parameter** hover and a jump to the
variable's first read in the template). Complete the variables the
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**
Expand Down
4 changes: 3 additions & 1 deletion examples/tempest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
Run `composer install`, then open this directory in an editor using PHPantom.
The `.view.php` files use the editor's PHP language-server connection.

- In `app/BookController.php`, complete or follow the view path.
- In `app/BookController.php`, complete or follow the view path and the
data names. Hover a data name to inspect its type in the template, or
follow it to the variable's first read.
- In `app/DatabaseQueries.php`, complete and follow model property names in
query builders and model helpers. Passing a model class name to `query()`
keeps the results typed as model objects.
Expand Down
3 changes: 2 additions & 1 deletion examples/tempest/app/BookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ final class BookController
public function index(): View
{
// Try: jump from this path to books.view.php, or complete its name.
return view(__DIR__ . '/books.view.php', books: [new Book('The PHP Book')]);
// Try: complete data names, follow them to their first template reads, or hover their types.
return view(__DIR__ . '/books.view.php', books: [new Book('The PHP Book')], title: 'Books');
}

// Try: complete inside {book}, or follow it to the parameter below.
Expand Down
1 change: 1 addition & 0 deletions examples/tempest/app/books.view.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<h1>{{ $title }}</h1>
<!-- Try: complete :foreach and navigate from $book->summary(); its type comes from BookController. -->
<section :foreach="$books as $book">
<!-- Try: follow x-card to its view, follow its attributes to their variables, and hover their types. -->
Expand Down
7 changes: 5 additions & 2 deletions examples/tempest/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,21 @@
}

$html = $renderer->render(new BookController()->index());
foreach (['<h2>The PHP Book</h2>', '<p>Read The PHP Book</p>'] as $expected) {
foreach (['<h1>Books</h1>', '<h2>The PHP Book</h2>', '<p>Read The PHP Book</p>'] as $expected) {
if (!str_contains($html, $expected)) {
throw new RuntimeException("Missing rendered content: {$expected}");
}
}
if (str_contains($html, 'No books yet.')) {
throw new RuntimeException('The nonempty loop rendered its forelse branch.');
}
$empty = $renderer->render(view(__DIR__ . '/app/books.view.php', books: []));
$empty = $renderer->render(view(__DIR__ . '/app/books.view.php', books: [], title: 'Empty books'));
if (!str_contains($empty, 'No books yet.')) {
throw new RuntimeException('The empty loop did not render its forelse branch.');
}
if (!str_contains($empty, '<h1>Empty books</h1>')) {
throw new RuntimeException('The named title did not reach the empty view.');
}

foreach ([true, false] as $showTitle) {
$card = $renderer->render(view(
Expand Down
5 changes: 5 additions & 0 deletions src/completion/handler/named_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ impl Backend {
None => return Vec::new(),
};

if let Some(items) =
self.tempest_view_named_completions(uri, &na_ctx, content, position, ctx)
{
return items;
}
if let Some(items) = self.tempest_route_named_completions(&na_ctx, content, position, ctx) {
return items;
}
Expand Down
3 changes: 3 additions & 0 deletions src/definition/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ impl Backend {
if let Some(location) = self.container_argument_definition(uri, content, position) {
return vec![location];
}
if let Some(location) = self.tempest_view_argument_definition(uri, content, position) {
return vec![location];
}
let routes = self.tempest_route_definition(uri, content, position);
if !routes.is_empty() {
return routes;
Expand Down
3 changes: 3 additions & 0 deletions src/hover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ impl Backend {
// runner, in-process tests) gets the same caches a request does.
let _scope = self.enter_request_scope_borrowed(uri, content);
let offset = crate::text_position::position_to_offset(content, position);
if let Some(hover) = self.tempest_view_argument_hover(uri, content, position) {
return Some(hover);
}

// An attribute read off a Twig variable is no span of the
// template's map: what it reads depends on the variable's type.
Expand Down
1 change: 1 addition & 0 deletions src/tempest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub(crate) mod preprocessor;
pub(crate) mod routes;
mod syntax;
mod view_data;
mod view_parameters;
mod wrappers;

pub(crate) use discovery::TempestState;
Expand Down
82 changes: 68 additions & 14 deletions src/tempest/view_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,17 +313,7 @@ impl RenderContext<'_> {
}
continue;
};
let path = args
.arguments
.iter()
.enumerate()
.find_map(|(i, arg)| match arg {
Argument::Named(arg) if arg.name.value == b"path" => Some(arg.value),
Argument::Positional(arg) if i == 0 && arg.ellipsis.is_none() => {
Some(arg.value)
}
_ => None,
});
let path = super::configuration::argument(args, "path", 0);
let Some(target) =
path.and_then(|path| path_definition(backend, uri, path, content, &loaders))
else {
Expand Down Expand Up @@ -362,20 +352,84 @@ fn view_arguments<'a>(
let Expression::Call(Call::Function(call)) = expr else {
return None;
};
view_call_arguments(call, file)
}

fn view_call_arguments<'a>(
call: &'a FunctionCall<'a>,
file: &FileContext,
) -> Option<&'a ArgumentList<'a>> {
let Expression::Identifier(name) = call.function else {
return None;
};
let offset = name.span().start.offset;
is_view_function(bytes_to_str(name.value()), file, name.span().start.offset)
.then_some(&call.argument_list)
}

/// Identify the view helper in the namespace block where its name was written.
pub(super) fn is_view_function(name: &str, file: &FileContext, offset: u32) -> bool {
let (resolved, _) = crate::names::function_lookup_names(
bytes_to_str(name.value()),
name,
file.use_map_at(offset),
&file.symbol_imports_at(offset).functions,
file.namespace_at(offset),
);
resolved
.trim_start_matches('\\')
.eq_ignore_ascii_case("Tempest\\View\\view")
.then_some(&call.argument_list)
}

/// A view call's target and the data names it already supplies.
pub(super) struct CallTarget {
/// Canonical URI of the template the call renders.
pub(super) uri: String,
/// Data names guaranteed by named arguments and known spread keys.
pub(super) supplied: std::collections::BTreeSet<String>,
}

/// Reverse the same path lookup used when inferring a template's incoming data.
pub(super) fn call_target(
backend: &Backend,
uri: &str,
content: &str,
file: &FileContext,
args_start: u32,
) -> Option<CallTarget> {
let loaders = backend.file_loaders(file);
crate::parser::with_parsed_program(content, "tempest_view_arguments", |program, content| {
let args = crate::parser::innermost_node_at(Node::Program(program), args_start, |node| {
let Node::FunctionCall(call) = node else {
return None;
};
view_call_arguments(call, file)
.filter(|args| args.left_parenthesis.end.offset == args_start)
})?;
let target = path_definition(
backend,
uri,
super::configuration::argument(args, "path", 0)?,
content,
&loaders,
)?;
let mut supplied = std::collections::BTreeSet::new();
for argument in args.arguments.iter() {
match argument {
Argument::Named(arg) => {
supplied.insert(bytes_to_str(arg.name.value).to_owned());
}
Argument::Positional(arg) if arg.ellipsis.is_some() => {
let mut vars = BTreeMap::new();
shape_vars(&expression_type(arg.value, content, &loaders), &mut vars);
supplied.extend(vars.into_keys());
}
_ => {}
}
}
Some(CallTarget {
uri: target.uri.to_string(),
supplied,
})
})
}

struct Calls;
Expand Down
Loading
Loading