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) + }) + ); +}