From aee601b9f2fda3dff4285e1dec685072917c6688 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 9 Nov 2026 11:03:43 +0600 Subject: [PATCH 1/2] Complete and navigate Tempest view data arguments Resolve the template through the existing view-data path lookup and offer its input names instead of the variadic params container. Navigate labels to their first source reads and show the template's normal PHP hover type. Read only the selected template, using indexed variable bindings and source maps to distinguish incoming data from locals and isolated scopes. Cover spreads, imports, captures, conditional bindings, incomplete calls, unsaved edits, stale metadata, and calls made from templates. --- src/completion/handler/named_args.rs | 5 + src/definition/resolve.rs | 3 + src/hover/mod.rs | 3 + src/tempest/mod.rs | 1 + src/tempest/view_data.rs | 82 +++- src/tempest/view_parameters.rs | 268 +++++++++++++ src/tempest/view_parameters/tests.rs | 134 +++++++ tests/integration/main.rs | 1 + tests/integration/tempest_view_parameters.rs | 402 +++++++++++++++++++ 9 files changed, 885 insertions(+), 14 deletions(-) create mode 100644 src/tempest/view_parameters.rs create mode 100644 src/tempest/view_parameters/tests.rs create mode 100644 tests/integration/tempest_view_parameters.rs 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) + }) + ); +} From 211efc6bad2286ae8e0d4cdc0db9967d70fb503b Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 9 Nov 2026 11:03:54 +0600 Subject: [PATCH 2/2] Demonstrate Tempest view data argument support Add a title input to the view playground and verify named data reaches both populated and empty views with Tempest's renderer. Document completion, navigation, and hover, credit the contribution, and remove the completed roadmap item while preserving release and sprint markers. --- docs/ARCHITECTURE.md | 6 ++++++ docs/CHANGELOG.md | 1 + docs/todo.md | 2 -- docs/todo/tempest.md | 22 ---------------------- examples/tempest/README.md | 4 +++- examples/tempest/app/BookController.php | 3 ++- examples/tempest/app/books.view.php | 1 + examples/tempest/assertions.php | 7 +++++-- 8 files changed, 18 insertions(+), 28 deletions(-) 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(