diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 85a351e15..7b9509e5d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a688d3f58..05fe4fe4d 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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.
diff --git a/docs/todo.md b/docs/todo.md
index 7d86b1cbd..f6f178cb2 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -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
@@ -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
diff --git a/docs/todo/tempest.md b/docs/todo/tempest.md
index da8eee715..8100dd803 100644
--- a/docs/todo/tempest.md
+++ b/docs/todo/tempest.md
@@ -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**
diff --git a/examples/tempest/README.md b/examples/tempest/README.md
index ccec2c8fd..46abf04ce 100644
--- a/examples/tempest/README.md
+++ b/examples/tempest/README.md
@@ -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.
diff --git a/examples/tempest/app/BookController.php b/examples/tempest/app/BookController.php
index d7c328139..81b330b78 100644
--- a/examples/tempest/app/BookController.php
+++ b/examples/tempest/app/BookController.php
@@ -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.
diff --git a/examples/tempest/app/books.view.php b/examples/tempest/app/books.view.php
index 0634a4e76..9ea3d9dba 100644
--- a/examples/tempest/app/books.view.php
+++ b/examples/tempest/app/books.view.php
@@ -1,3 +1,4 @@
+
{{ $title }}
diff --git a/examples/tempest/assertions.php b/examples/tempest/assertions.php
index 979a839d1..3f3e8d563 100644
--- a/examples/tempest/assertions.php
+++ b/examples/tempest/assertions.php
@@ -73,7 +73,7 @@
}
$html = $renderer->render(new BookController()->index());
-foreach (['The PHP Book
', 'Read The PHP Book
'] as $expected) {
+foreach (['Books
', 'The PHP Book
', 'Read The PHP Book
'] as $expected) {
if (!str_contains($html, $expected)) {
throw new RuntimeException("Missing rendered content: {$expected}");
}
@@ -81,10 +81,13 @@
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, 'Empty books
')) {
+ throw new RuntimeException('The named title did not reach the empty view.');
+}
foreach ([true, false] as $showTitle) {
$card = $renderer->render(view(
diff --git a/src/completion/handler/named_args.rs b/src/completion/handler/named_args.rs
index 3bff32884..c7311b196 100644
--- a/src/completion/handler/named_args.rs
+++ b/src/completion/handler/named_args.rs
@@ -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;
}
diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs
index 02d6ef51f..de87220fb 100644
--- a/src/definition/resolve.rs
+++ b/src/definition/resolve.rs
@@ -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;
diff --git a/src/hover/mod.rs b/src/hover/mod.rs
index 458779fe3..a90e857c5 100644
--- a/src/hover/mod.rs
+++ b/src/hover/mod.rs
@@ -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.
diff --git a/src/tempest/mod.rs b/src/tempest/mod.rs
index 76efe6b51..c26f7e7fb 100644
--- a/src/tempest/mod.rs
+++ b/src/tempest/mod.rs
@@ -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;
diff --git a/src/tempest/view_data.rs b/src/tempest/view_data.rs
index d964f0033..60c8f4bee 100644
--- a/src/tempest/view_data.rs
+++ b/src/tempest/view_data.rs
@@ -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 {
@@ -362,12 +352,24 @@ 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),
@@ -375,7 +377,59 @@ fn view_arguments<'a>(
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,
+}
+
+/// 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 {
+ 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;
diff --git a/src/tempest/view_parameters.rs b/src/tempest/view_parameters.rs
new file mode 100644
index 000000000..b4adb71fe
--- /dev/null
+++ b/src/tempest/view_parameters.rs
@@ -0,0 +1,268 @@
+//! Named view data completes and navigates through the receiving template's PHP.
+
+use std::collections::{BTreeMap, HashMap};
+
+use tower_lsp_server::gen_lsp_types::*;
+
+use crate::Backend;
+use crate::atom::{Atom, atom};
+use crate::completion::named_args::NamedArgContext;
+use crate::symbol_map::{SymbolKind, VarDefKind};
+use crate::text_position::{LineIndex, byte_range_to_lsp_range, position_to_offset};
+use crate::types::FileContext;
+
+use super::view_data::{call_target, is_view_function};
+
+#[derive(Clone, Copy)]
+struct Read {
+ /// PHP coordinates for the regular definition and hover pipelines.
+ range: Range,
+ /// Source order can differ from PHP order when directives reorder attributes.
+ source: Position,
+}
+
+impl Backend {
+ fn tempest_view_input_reads(&self, uri: &str, php: &str) -> BTreeMap {
+ let Some(map) = self
+ .symbol_map_for(uri)
+ .filter(|map| map.source(php).is_some())
+ else {
+ return BTreeMap::new();
+ };
+ let lines = LineIndex::new(php);
+ // Tempest lowers the template into a generated wrapper function.
+ let root_scope = if self.is_tempest_view(uri) {
+ map.scopes.first().map_or(0, |&(start, _)| start)
+ } else {
+ 0
+ };
+ let range = |start: u32, end: u32| Range {
+ start: lines.position(start as usize),
+ end: lines.position(end as usize),
+ };
+ // Injected type bindings have no source range and do not make data local.
+ let definitions = map.var_defs.iter().filter(|definition| {
+ definition.scope_start == root_scope
+ && !matches!(
+ definition.kind,
+ VarDefKind::DocblockVar
+ | VarDefKind::DocblockParam
+ | VarDefKind::Property
+ | VarDefKind::CompoundAssignment
+ | VarDefKind::ClosureCapture
+ )
+ && self
+ .try_translate_template_range(
+ uri,
+ range(
+ definition.offset,
+ definition.offset + definition.name.len() as u32 + 1,
+ ),
+ )
+ .is_some()
+ });
+ let mut bindings = Vec::new();
+ for definition in definitions {
+ bindings.push((definition.effective_from, definition.name.as_str(), true));
+ if definition.nesting_depth > 0 && definition.block_end < u32::MAX {
+ bindings.push((definition.block_end + 1, definition.name.as_str(), false));
+ }
+ }
+ bindings.sort_unstable_by_key(|&(offset, _, _)| offset);
+ let mut bindings = bindings.into_iter().peekable();
+ let mut locals = HashMap::new();
+ let mut reads: BTreeMap = BTreeMap::new();
+ for span in &map.spans {
+ while bindings
+ .peek()
+ .is_some_and(|&(offset, _, _)| offset <= span.start)
+ {
+ let (_, name, active) = bindings.next().unwrap();
+ let count = locals.entry(name).or_insert(0usize);
+ if active {
+ *count += 1;
+ } else {
+ *count -= 1;
+ }
+ }
+ let SymbolKind::Variable { name } = &span.kind else {
+ continue;
+ };
+ if !super::inference::variable_name(name)
+ || crate::diagnostics::helpers::SUPERGLOBALS
+ .iter()
+ .any(|global| global.strip_prefix('$') == Some(name))
+ || locals.get(name.as_str()).is_some_and(|&count| count > 0)
+ || !input_scope(&map, name, span.start, root_scope)
+ || map.var_def_kind_at(name, span.start).is_some_and(|kind| {
+ !matches!(
+ kind,
+ VarDefKind::CompoundAssignment | VarDefKind::ClosureCapture
+ )
+ })
+ {
+ continue;
+ }
+ let php_range = range(span.start, span.end);
+ let Some(source) = self.try_translate_template_range(uri, php_range) else {
+ continue;
+ };
+ let read = Read {
+ range: php_range,
+ source: source.start,
+ };
+ let previous = reads.entry(*name).or_insert(read);
+ if (read.source.line, read.source.character)
+ < (previous.source.line, previous.source.character)
+ {
+ *previous = read;
+ }
+ }
+ reads
+ }
+
+ /// Complete template inputs the call has not already supplied.
+ pub(crate) fn tempest_view_named_completions(
+ &self,
+ uri: &str,
+ context: &NamedArgContext,
+ content: &str,
+ position: Position,
+ file: &FileContext,
+ ) -> Option> {
+ let offset = position_to_offset(content, position);
+ if !self.is_tempest_project() || !is_view_function(&context.call_expression, file, offset) {
+ return None;
+ }
+ let start =
+ (offset as usize).checked_sub(context.prefix.len() + context.args_text.len())?;
+ let target = call_target(self, uri, content, file, start as u32).or_else(|| {
+ // Preserve byte offsets, including CRLF, when a trailing call is incomplete.
+ let patched = format!(
+ "{});",
+ content.get(..offset as usize - context.prefix.len())?
+ );
+ call_target(self, uri, &patched, file, start as u32)
+ })?;
+ self.with_file_content("tempest_view_parameters", &target.uri, None, |php, _| {
+ self.tempest_view_input_reads(&target.uri, php)
+ .into_keys()
+ .filter(|name| {
+ name != "path"
+ && name.starts_with(&context.prefix)
+ && !target.supplied.contains(name.as_str())
+ })
+ .map(|name| CompletionItem {
+ label: format!("{name}:"),
+ kind: Some(CompletionItemKind::Variable),
+ detail: Some("View parameter".to_owned()),
+ insert_text: Some(format!("{name}: ")),
+ ..Default::default()
+ })
+ .collect()
+ })
+ }
+
+ fn tempest_view_argument_target(
+ &self,
+ uri: &str,
+ content: &str,
+ position: Position,
+ ) -> Option<(String, Atom, Range)> {
+ if !self.is_tempest_project() {
+ return None;
+ }
+ let offset = position_to_offset(content, position);
+ let map = self
+ .symbol_map_for(uri)
+ .filter(|map| map.source(content).is_some())?;
+ let call = map.find_enclosing_call_site(offset)?;
+ let name = call
+ .named_arg_indices
+ .iter()
+ .zip(call.named_arg_names.iter())
+ .find_map(|(&index, name)| {
+ let start = call.arg_offsets[index as usize];
+ (name != "path"
+ && start <= offset
+ && offset <= start + name.len() as u32
+ && content.get(start as usize..start as usize + name.len()) == Some(name))
+ .then_some((name, start))
+ })?;
+ let file = self.file_context(uri);
+ if !is_view_function(call.call_expression, &file, offset) {
+ return None;
+ }
+ let target = call_target(self, uri, content, &file, call.args_start)?;
+ Some((
+ target.uri,
+ atom(name.0),
+ byte_range_to_lsp_range(content, name.1 as usize, name.1 as usize + name.0.len()),
+ ))
+ }
+
+ /// Navigate a data label to its first read in the target template.
+ pub(crate) fn tempest_view_argument_definition(
+ &self,
+ uri: &str,
+ content: &str,
+ position: Position,
+ ) -> Option {
+ let (target, name, _) = self.tempest_view_argument_target(uri, content, position)?;
+ self.with_file_content(
+ "tempest_view_parameter_definition",
+ &target,
+ None,
+ |php, _| {
+ Some(Location {
+ uri: crate::util::parse_url(&target)?,
+ range: self
+ .tempest_view_input_reads(&target, php)
+ .get(&name)?
+ .range,
+ })
+ },
+ )
+ .flatten()
+ }
+
+ /// Show a view parameter with the same type its template read has.
+ pub(crate) fn tempest_view_argument_hover(
+ &self,
+ uri: &str,
+ content: &str,
+ position: Position,
+ ) -> Option {
+ let (target, name, range) = self.tempest_view_argument_target(uri, content, position)?;
+ self.with_file_content("tempest_view_parameter_hover", &target, None, |php, _| {
+ let read = *self.tempest_view_input_reads(&target, php).get(&name)?;
+ let mut contents = format!("**View parameter** `${name}`");
+ if let Some(Hover {
+ contents: Contents::MarkupContent(typed),
+ ..
+ }) = self.handle_hover(&target, php, read.range.start)
+ {
+ contents.push_str("\n\n");
+ contents.push_str(&typed.value);
+ }
+ let mut hover = crate::hover::formatting::make_hover(contents);
+ hover.range = Some(range);
+ Some(hover)
+ })
+ .flatten()
+ }
+}
+
+fn input_scope(map: &crate::symbol_map::SymbolMap, name: &str, offset: u32, root: u32) -> bool {
+ let mut scope = map.find_enclosing_scope(offset);
+ while scope != root && map.is_arrow_fn_scope(scope) {
+ if map.find_var_definition(name, offset, scope).is_some() {
+ return false;
+ }
+ scope = map.find_enclosing_scope(scope - 1);
+ }
+ scope == root
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/tempest/view_parameters/tests.rs b/src/tempest/view_parameters/tests.rs
new file mode 100644
index 000000000..0ae262981
--- /dev/null
+++ b/src/tempest/view_parameters/tests.rs
@@ -0,0 +1,134 @@
+use super::*;
+use crate::test_fixtures::make_backend;
+
+#[test]
+fn view_parameter_reads_keep_template_scope_and_reject_missing_or_stale_maps() {
+ let backend = make_backend();
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("page.view.php");
+ let uri = crate::util::path_to_uri(&path);
+ assert!(backend.tempest_view_input_reads(&uri, "{{ $title }}";
+ std::fs::write(&path, source).unwrap();
+ backend.update_ast(&uri, source);
+ let php = backend.analysable_content(&uri).unwrap();
+ let reads = backend.tempest_view_input_reads(&uri, &php);
+ assert_eq!(
+ reads.keys().map(Atom::as_str).collect::>(),
+ ["title"]
+ );
+ assert!(
+ backend
+ .tempest_view_input_reads(&uri, " (Backend, tempfile::TempDir, Uri, Uri) {
+ let (backend, dir) = create_psr4_workspace(
+ COMPOSER,
+ &[
+ ("framework/View.php", FRAMEWORK),
+ (
+ "app/Book.php",
+ " Vec {
+ let at = position_after(source, needle);
+ response_items(completion_response(backend, uri, at.line, at.character, None).await)
+ .unwrap_or_default()
+}
+
+async fn definitions(backend: &Backend, uri: &Uri, source: &str, needle: &str) -> Vec {
+ let at = position_of(source, needle);
+ definition_locations(goto_definition_at(backend, uri, at.line, at.character).await)
+}
+
+#[tokio::test]
+async fn tempest_view_parameters_complete_unused_inputs_and_navigate_first_reads() {
+ let caller = "\r\n\r\n📚 {{ $book->title }} {{ $book->title }}
\r\n";
+ let (backend, _dir, uri, view) = project(caller, template).await;
+ let offered = items(&backend, &uri, caller, "render('./page.view.php', ").await;
+ for name in ["book:", "title:"] {
+ let item = offered
+ .iter()
+ .find(|item| item.label == name)
+ .unwrap_or_else(|| panic!("{name}: {offered:?}"));
+ assert_eq!(item.detail.as_deref(), Some("View parameter"));
+ assert_eq!(
+ item.insert_text.as_deref(),
+ Some(format!("{name} ").as_str())
+ );
+ }
+ assert!(
+ !offered
+ .iter()
+ .any(|item| matches!(item.label.as_str(), "params:" | "path:"))
+ );
+ let filtered = items(&backend, &uri, caller, "path: './page.view.php', ti").await;
+ assert!(filtered.iter().any(|item| item.label == "title:"));
+ assert!(!filtered.iter().any(|item| item.label == "book:"));
+ let later = items(&backend, &uri, caller, "render('./page.view.php', bo").await;
+ assert!(later.iter().any(|item| item.label == "book:"));
+ assert!(!later.iter().any(|item| item.label == "title:"));
+ for (name, read, ty) in [
+ ("book", "{{ $book", "Book"),
+ ("title", ":title=\"$title", "string"),
+ ] {
+ let needle = format!("{name}:");
+ let start = position_of(caller, &needle);
+ let end = Position::new(start.line, start.character + name.len() as u32);
+ let read_end = position_after(template, read);
+ let expected = vec![Location {
+ uri: view.clone(),
+ range: Range {
+ start: Position::new(read_end.line, read_end.character - name.len() as u32 - 1),
+ end: read_end,
+ },
+ }];
+ for at in [start, end] {
+ assert_eq!(
+ definition_locations(
+ goto_definition_at(&backend, &uri, at.line, at.character).await
+ ),
+ expected
+ );
+ let hover = hover_response(&backend, &uri, at.line, at.character)
+ .await
+ .unwrap();
+ let text = hover_text(&hover);
+ assert!(
+ text.contains("View parameter") && text.contains(ty),
+ "{hover:?}"
+ );
+ assert_eq!(hover.range, Some(Range { start, end }));
+ }
+ }
+}
+
+#[tokio::test]
+async fn tempest_view_parameters_resolve_paths_through_the_shared_expression_engine() {
+ let caller = r" 'Passed'], );
+/** @var array{title?: string} $maybe */
+view('./page.view.php', ...$maybe, ti);
+";
+ let (backend, _dir, uri, view) = project(caller, "{{ $title }}
").await;
+ for needle in ["title: 'Variable'", "title: 'Constant'"] {
+ let found = definitions(&backend, &uri, caller, needle).await;
+ assert_eq!(found.len(), 1, "{needle}: {found:?}");
+ assert_eq!(found[0].uri, view);
+ }
+ assert!(
+ items(&backend, &uri, caller, "path: TEMPLATE, ")
+ .await
+ .iter()
+ .any(|item| item.label == "title:")
+ );
+ assert!(
+ !items(&backend, &uri, caller, "'Passed'], ")
+ .await
+ .iter()
+ .any(|item| item.label == "title:")
+ );
+ assert!(
+ items(&backend, &uri, caller, "...$maybe, ti")
+ .await
+ .iter()
+ .any(|item| item.label == "title:")
+ );
+}
+
+#[tokio::test]
+async fn tempest_view_parameters_ignore_annotations_locals_nested_scopes_and_superglobals() {
+ let caller = r" $arrowInput . $parameter;
+$closure = function () use ($captured) { return $captured; };
+function nested(string $parameter) { echo $parameter, $hidden; }
+unset($gone);
+$optional ??= 'default';
+?>
+
+{{ $book->title }} {{ $external }}
+Empty
+{{ $_GET['page'] }} {{ $this->title }} {{ $copy }}
+"#;
+ let (backend, _dir, uri, _) = project(caller, template).await;
+ let offered = items(&backend, &uri, caller, "'./page.view.php', ").await;
+ for name in [
+ "books:",
+ "external:",
+ "optional:",
+ "arrowInput:",
+ "captured:",
+ ] {
+ assert!(
+ offered.iter().any(|item| item.label == name),
+ "{name}: {offered:?}"
+ );
+ }
+ for name in [
+ "annotationOnly:",
+ "local:",
+ "copy:",
+ "book:",
+ "parameter:",
+ "hidden:",
+ "gone:",
+ "comment:",
+ "_GET:",
+ "this:",
+ ] {
+ assert!(
+ !offered.iter().any(|item| item.label == name),
+ "{name}: {offered:?}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn tempest_view_parameters_keep_incomplete_calls_and_unsaved_templates_editable() {
+ let caller = "New template\n{{ $heading }}";
+ open_php(&backend, &view, changed).await;
+ assert!(
+ definitions(&backend, &uri, complete, "title:")
+ .await
+ .is_empty()
+ );
+ let found = definitions(&backend, &uri, complete, "heading:").await;
+ assert_eq!(found[0].range.start, position_of(changed, "$heading"));
+ assert!(
+ hover_response(
+ &backend,
+ &uri,
+ position_of(complete, "title:").line,
+ position_of(complete, "title:").character
+ )
+ .await
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn tempest_view_parameters_leave_other_calls_values_and_custom_view_constructors_alone() {
+ let caller = r"
+{{ $title }}
";
+ let (backend, _dir, uri, view) = project(caller, template).await;
+ let offered = items(&backend, &uri, caller, "'./page.view.php', ").await;
+ assert!(
+ offered.iter().any(|item| item.label == "title:"),
+ "{offered:?}"
+ );
+ let found = definitions(&backend, &uri, caller, "title: 'Fallback'").await;
+ assert_eq!(found.len(), 1, "{found:?}");
+ assert_eq!(found[0].uri, view);
+ assert_eq!(
+ found[0].range.start,
+ position_of(template, "$title }}")
+ );
+}
+
+#[tokio::test]
+async fn tempest_view_parameters_follow_each_namespace_blocks_function_imports() {
+ let caller = r"Count\n{{ $count }}";
+ let detail_path = dir.path().join("app/detail.view.php");
+ std::fs::write(&detail_path, detail).unwrap();
+ let target = Uri::from_file_path(dunce::canonicalize(&detail_path).unwrap()).unwrap();
+ let source = "\n📚 {{ view('./detail.view.php', count: 42) }}
";
+ open_php(&backend, &page, source).await;
+ open_php(&backend, &target, detail).await;
+ let found = definitions(&backend, &page, source, "count:").await;
+ assert_eq!(found.len(), 1, "{found:?}");
+ assert_eq!(found[0].uri, target);
+ assert_eq!(
+ found[0].range,
+ Range {
+ start: position_of(detail, "$count"),
+ end: position_after(detail, "$count")
+ }
+ );
+ let read = position_of(detail, "$count");
+ let expected = hover_response(&backend, &target, read.line, read.character)
+ .await
+ .unwrap();
+ let at = position_of(source, "count:");
+ let hover = hover_response(&backend, &page, at.line, at.character)
+ .await
+ .unwrap();
+ assert!(
+ hover_text(&hover).contains(hover_text(&expected)),
+ "{hover:?} vs {expected:?}"
+ );
+ assert_eq!(
+ hover.range,
+ Some(Range {
+ start: at,
+ end: Position::new(at.line, at.character + 5)
+ })
+ );
+}