1use std::sync::Arc;
2
3#[cfg(feature = "http")]
4use http::{HeaderMap, StatusCode};
5use topcoat_core::context::Cx;
6
7use crate::{
8 Formatter,
9 buffer::{InstructionPtr, Renderer, ViewBuffer, ViewBufferId, ViewBufferScope},
10};
11
12#[derive(Debug, Default, Clone)]
31pub struct ViewHandle {
32 repr: ViewRepr,
33}
34
35#[derive(Debug, Clone)]
39pub(super) enum ViewRepr {
40 Static(&'static str),
42 Scoped {
45 buffer: ViewBufferId,
46 entry: InstructionPtr,
47 size_hint: usize,
50 },
51 Owned {
54 buffer: Arc<ViewBuffer>,
55 entry: InstructionPtr,
56 size_hint: usize,
59 },
60}
61
62impl Default for ViewRepr {
63 #[inline]
64 fn default() -> Self {
65 Self::Static("")
66 }
67}
68
69impl ViewHandle {
70 #[inline]
73 pub(super) fn from_scope(
74 buffer: ViewBufferId,
75 entry: InstructionPtr,
76 size_hint: usize,
77 ) -> Self {
78 Self {
79 repr: ViewRepr::Scoped {
80 buffer,
81 entry,
82 size_hint,
83 },
84 }
85 }
86
87 #[inline]
89 pub(super) fn repr(self) -> ViewRepr {
90 self.repr
91 }
92
93 #[inline]
96 #[must_use]
97 pub fn size_hint(&self) -> usize {
98 match &self.repr {
99 ViewRepr::Static(body) => body.len(),
100 ViewRepr::Scoped { size_hint, .. } | ViewRepr::Owned { size_hint, .. } => *size_hint,
101 }
102 }
103
104 #[must_use]
114 pub(crate) fn seal(self, buffer: ViewBuffer) -> Self {
115 match self.repr {
116 ViewRepr::Static(_) | ViewRepr::Owned { .. } => self,
117 ViewRepr::Scoped {
118 buffer: id,
119 entry,
120 size_hint,
121 } => {
122 assert!(
123 id == buffer.id(),
124 "tried to seal a view into a buffer it was not built in",
125 );
126 Self {
127 repr: ViewRepr::Owned {
128 buffer: Arc::new(buffer),
129 entry,
130 size_hint,
131 },
132 }
133 }
134 }
135 }
136
137 #[inline]
139 #[must_use]
140 pub fn empty() -> Self {
141 Self::default()
142 }
143
144 #[inline]
149 #[must_use]
150 pub fn is_empty(&self) -> bool {
151 matches!(self.repr, ViewRepr::Static(""))
152 }
153
154 #[inline]
157 #[must_use]
158 pub const fn unescaped_unchecked(body: &'static str) -> Self {
159 Self {
160 repr: ViewRepr::Static(body),
161 }
162 }
163
164 #[cfg_attr(
166 feature = "http",
167 doc = "",
168 doc = "Status codes and headers declared in the view are discarded;",
169 doc = "[`render_response`](Self::render_response) collects them."
170 )]
171 #[must_use]
178 #[track_caller]
179 pub fn render(self, cx: &Cx) -> String {
180 let mut html = String::with_capacity(self.size_hint());
181 self.render_into(cx, &mut Formatter::new(&mut html));
182 html
183 }
184
185 #[cfg(feature = "http")]
201 #[must_use]
202 #[track_caller]
203 pub fn render_response(self, cx: &Cx) -> RenderedResponse {
204 let mut html = String::with_capacity(self.size_hint());
205 let mut f = Formatter::new(&mut html);
206 self.render_into(cx, &mut f);
207 let (status_code, headers) = f.into_recorded();
208 RenderedResponse {
209 html,
210 status_code,
211 headers,
212 }
213 }
214
215 #[track_caller]
229 pub fn render_into(self, cx: &Cx, f: &mut Formatter<'_>) {
230 match self.repr {
231 ViewRepr::Static(body) => f.write_str(body),
232 ViewRepr::Scoped { buffer, entry, .. } => ViewBufferScope::with(|active| {
233 assert!(
234 active.id() == buffer,
235 "tried to render a nested view handle outside the build it was built in",
236 );
237 Renderer::new(active, entry).execute(cx, f);
238 }),
239 ViewRepr::Owned { buffer, entry, .. } => {
240 Renderer::new(&buffer, entry).execute(cx, f);
241 }
242 }
243 }
244}
245
246#[cfg(feature = "http")]
251#[derive(Debug)]
252#[non_exhaustive]
253pub struct RenderedResponse {
254 pub html: String,
256 pub status_code: Option<StatusCode>,
258 pub headers: HeaderMap,
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::PartsWriter;
269
270 fn nested(buffer: &mut ViewBuffer, f: impl FnOnce(&mut PartsWriter<'_>)) -> ViewHandle {
273 buffer.block(f)
274 }
275
276 fn owned(f: impl FnOnce(&mut PartsWriter<'_>)) -> ViewHandle {
279 ViewBuffer::build(f)
280 }
281
282 fn in_scope<R>(f: impl FnOnce() -> R) -> (R, ViewBuffer) {
285 let mut slot = Some(Box::new(ViewBuffer::new()));
286 let output = {
287 let _buffer = ViewBufferScope::new(&mut slot);
288 f()
289 };
290 (output, *slot.expect("the buffer was swapped back on exit"))
291 }
292
293 #[test]
294 fn static_views_render_without_a_buffer() {
295 assert_eq!(ViewHandle::empty().render(&Cx::default()), "");
296 let view = ViewHandle::unescaped_unchecked("<b>raw</b>");
297 assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
298 }
299
300 #[test]
301 fn push_view_splices_nested_views() {
302 let mut buffer = ViewBuffer::new();
303 let inner = nested(&mut buffer, |parts| {
304 parts.push_str("a < b");
305 });
306 let outer = nested(&mut buffer, |parts| {
307 parts.push_str_unescaped("<p>");
308 parts.push_view_handle(inner);
309 parts.push_str_unescaped("</p>");
310 });
311 assert_eq!(outer.seal(buffer).render(&Cx::default()), "<p>a < b</p>");
312 }
313
314 #[test]
315 fn document_order_follows_splice_order_not_buffer_order() {
316 let mut buffer = ViewBuffer::new();
317 let second = nested(&mut buffer, |parts| {
319 parts.push_str("B");
320 });
321 let first = nested(&mut buffer, |parts| {
322 parts.push_str("A");
323 });
324 let outer = nested(&mut buffer, |parts| {
325 parts.push_view_handle(first);
326 parts.push_view_handle(second);
327 });
328 assert_eq!(outer.seal(buffer).render(&Cx::default()), "AB");
329 }
330
331 #[test]
332 fn sealed_views_own_their_buffer() {
333 let view = owned(|parts| {
334 parts.push_str("a < b");
335 });
336 assert!(matches!(view.repr, ViewRepr::Owned { .. }));
337 assert_eq!(view.render(&Cx::default()), "a < b");
338 }
339
340 #[test]
341 fn owned_views_splice_across_buffers() {
342 let inner = owned(|parts| {
343 parts.push_str("a < b");
344 });
345 let outer = owned(|parts| {
346 parts.push_str_unescaped("<p>");
347 parts.push_view_handle(inner);
348 parts.push_str_unescaped("</p>");
349 });
350 assert_eq!(outer.render(&Cx::default()), "<p>a < b</p>");
351 }
352
353 #[test]
354 fn owned_views_are_send_and_sync() {
355 fn assert_send_sync<T: Send + Sync>(_value: &T) {}
356
357 let view = owned(|parts| {
358 parts.push_str("x");
359 });
360 assert_send_sync(&view);
361 }
362
363 #[test]
364 fn static_views_are_spliced_verbatim() {
365 let outer = owned(|parts| {
366 parts.push_view_handle(ViewHandle::unescaped_unchecked("<hr>"));
367 parts.push_view_handle(ViewHandle::empty());
368 });
369 assert_eq!(outer.render(&Cx::default()), "<hr>");
370 }
371
372 #[test]
373 fn size_hint_accumulates_across_splices() {
374 let mut buffer = ViewBuffer::new();
375 let inner = nested(&mut buffer, |parts| {
376 parts.push_str_unescaped("12345678");
377 });
378 let outer = nested(&mut buffer, |parts| {
379 parts.push_view_handle(inner.clone());
380 parts.push_view_handle(inner);
381 parts.push_view_handle(ViewHandle::unescaped_unchecked("<hr>"));
382 });
383 let ViewRepr::Scoped { size_hint, .. } = outer.repr() else {
384 panic!("expected a nested view");
385 };
386 assert_eq!(size_hint, 8 + 8 + 4);
387 }
388
389 #[test]
390 fn a_nested_view_renders_inside_its_build() {
391 let (rendered, _buffer) = in_scope(|| {
392 let view = ViewBufferScope::with(|buffer| {
393 buffer.block(|parts| {
394 parts.push_str("a < b");
395 })
396 });
397 view.render(&Cx::default())
398 });
399 assert_eq!(rendered, "a < b");
400 }
401
402 #[test]
403 #[should_panic(expected = "outside of a `ViewBufferScope`")]
404 fn rendering_a_nested_view_outside_any_build_panics() {
405 let mut buffer = ViewBuffer::new();
406 let view = nested(&mut buffer, |_parts| {});
407 let _ = view.render(&Cx::default());
408 }
409
410 #[test]
411 #[should_panic(expected = "outside the build it was built in")]
412 fn rendering_a_nested_view_inside_a_different_build_panics() {
413 let mut buffer = ViewBuffer::new();
414 let view = nested(&mut buffer, |_parts| {});
415 in_scope(|| view.render(&Cx::default()));
416 }
417
418 #[test]
419 #[should_panic(expected = "outside the `view!` invocation it was built in")]
420 fn splicing_a_nested_view_from_a_different_buffer_panics() {
421 let mut built_in = ViewBuffer::new();
422 let view = nested(&mut built_in, |_parts| {});
423 let mut other = ViewBuffer::new();
424 nested(&mut other, |parts| {
425 parts.push_view_handle(view);
426 });
427 }
428
429 #[test]
430 #[should_panic(expected = "tried to seal a view into a buffer it was not built in")]
431 fn sealing_a_view_into_a_different_buffer_panics() {
432 let mut built_in = ViewBuffer::new();
433 let view = nested(&mut built_in, |_parts| {});
434 let _ = view.seal(ViewBuffer::new());
435 }
436
437 #[cfg(feature = "http")]
438 mod response {
439 use http::{
440 HeaderMap, HeaderName, HeaderValue, StatusCode,
441 header::{CACHE_CONTROL, SET_COOKIE},
442 };
443
444 use super::*;
445 use crate::NodeViewParts;
446
447 fn push_node(cx: &Cx, parts: &mut PartsWriter<'_>, value: impl NodeViewParts) {
448 value.into_view_parts(cx, parts);
449 }
450
451 #[test]
452 fn status_code_is_recorded_and_renders_nothing() {
453 let cx = &Cx::default();
454 let view = owned(|parts| {
455 push_node(cx, parts, "a");
456 push_node(cx, parts, StatusCode::NOT_FOUND);
457 push_node(cx, parts, "b");
458 });
459
460 let rendered = view.render_response(cx);
461 assert_eq!(rendered.html, "ab");
462 assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
463 assert!(rendered.headers.is_empty());
464 }
465
466 #[test]
467 fn render_response_without_declarations_is_empty() {
468 let cx = &Cx::default();
469 let view = owned(|parts| {
470 push_node(cx, parts, "a");
471 });
472
473 let rendered = view.render_response(cx);
474 assert_eq!(rendered.html, "a");
475 assert_eq!(rendered.status_code, None);
476 assert!(rendered.headers.is_empty());
477 }
478
479 #[test]
480 fn render_discards_declarations() {
481 let cx = &Cx::default();
482 let view = owned(|parts| {
483 push_node(cx, parts, StatusCode::NOT_FOUND);
484 push_node(
485 cx,
486 parts,
487 (CACHE_CONTROL, HeaderValue::from_static("no-store")),
488 );
489 push_node(cx, parts, "a");
490 });
491
492 assert_eq!(view.render(cx), "a");
493 }
494
495 #[test]
496 fn first_status_code_wins() {
497 let cx = &Cx::default();
498 let view = owned(|parts| {
499 push_node(cx, parts, StatusCode::NOT_FOUND);
500 push_node(cx, parts, StatusCode::OK);
501 });
502
503 let rendered = view.render_response(cx);
504 assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
505 }
506
507 #[test]
508 fn first_mention_of_a_header_name_wins() {
509 let cx = &Cx::default();
510 let view = owned(|parts| {
511 push_node(
512 cx,
513 parts,
514 (CACHE_CONTROL, HeaderValue::from_static("no-store")),
515 );
516 let mut later = HeaderMap::new();
517 later.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
518 later.insert(
519 HeaderName::from_static("x-extra"),
520 HeaderValue::from_static("1"),
521 );
522 push_node(cx, parts, later);
523 });
524
525 let rendered = view.render_response(cx);
526 assert_eq!(rendered.headers[CACHE_CONTROL], "no-store");
527 assert_eq!(rendered.headers["x-extra"], "1");
528 }
529
530 #[test]
531 fn one_map_keeps_all_values_for_a_name() {
532 let cx = &Cx::default();
533 let mut first = HeaderMap::new();
534 first.append(SET_COOKIE, HeaderValue::from_static("a=1"));
535 first.append(SET_COOKIE, HeaderValue::from_static("b=2"));
536 let mut later = HeaderMap::new();
537 later.insert(SET_COOKIE, HeaderValue::from_static("c=3"));
538
539 let view = owned(|parts| {
540 push_node(cx, parts, first);
541 push_node(cx, parts, later);
542 });
543
544 let rendered = view.render_response(cx);
545 let cookies: Vec<_> = rendered.headers.get_all(SET_COOKIE).iter().collect();
546 assert_eq!(cookies, ["a=1", "b=2"]);
547 }
548
549 #[test]
550 fn placement_decides_precedence_across_nested_views() {
551 let cx = &Cx::default();
552 let inner = owned(|parts| {
553 push_node(cx, parts, StatusCode::NOT_FOUND);
554 push_node(cx, parts, "inner");
555 });
556
557 let outer = owned(|parts| {
559 push_node(cx, parts, StatusCode::FORBIDDEN);
560 parts.push_view_handle(inner.clone());
561 });
562 let rendered = outer.render_response(cx);
563 assert_eq!(rendered.status_code, Some(StatusCode::FORBIDDEN));
564
565 let outer = owned(|parts| {
567 parts.push_view_handle(inner);
568 push_node(cx, parts, StatusCode::FORBIDDEN);
569 });
570 let rendered = outer.render_response(cx);
571 assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
572 }
573 }
574}