use core::fmt;
use std::borrow::Cow;
#[cfg(feature = "http")]
use http::{HeaderMap, StatusCode};
use smallvec::SmallVec;
use topcoat_core::context::Cx;
use crate::{Formatter, HtmlContext, HtmlWriter};
#[derive(Debug, Default, Clone)]
pub struct View {
part: ViewPart,
}
impl View {
#[doc(hidden)]
#[inline]
#[must_use]
pub fn new(parts: ViewParts) -> Self {
Self { part: parts.into() }
}
#[inline]
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[inline]
#[must_use]
pub const fn unescaped_unchecked(body: &'static str) -> Self {
Self {
part: ViewPart::unescaped(body),
}
}
#[cfg_attr(
feature = "http",
doc = "",
doc = "Status codes and headers declared in the view are discarded;",
doc = "[`render_response`](Self::render_response) collects them."
)]
pub fn render(&self, cx: &Cx) -> String {
let mut buf = String::with_capacity(self.part.size_hint());
let mut f = Formatter::new(&mut buf);
self.part.render(cx, &mut f);
buf
}
#[cfg(feature = "http")]
#[must_use]
pub fn render_response(&self, cx: &Cx) -> RenderedResponse {
let mut html = String::with_capacity(self.part.size_hint());
let mut f = Formatter::new(&mut html);
self.part.render(cx, &mut f);
let (status_code, headers) = f.into_recorded();
RenderedResponse {
html,
status_code,
headers,
}
}
#[inline]
pub(crate) fn into_part(self) -> ViewPart {
self.part
}
}
#[cfg(feature = "http")]
#[derive(Debug)]
#[non_exhaustive]
pub struct RenderedResponse {
pub html: String,
pub status_code: Option<StatusCode>,
pub headers: HeaderMap,
}
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub enum ViewPart {
#[default]
Empty,
#[non_exhaustive]
Bool(bool),
#[non_exhaustive]
I8(i8),
#[non_exhaustive]
I16(i16),
#[non_exhaustive]
I32(i32),
#[non_exhaustive]
I64(i64),
#[non_exhaustive]
I128(i128),
#[non_exhaustive]
Isize(isize),
#[non_exhaustive]
U8(u8),
#[non_exhaustive]
U16(u16),
#[non_exhaustive]
U32(u32),
#[non_exhaustive]
U64(u64),
#[non_exhaustive]
U128(u128),
#[non_exhaustive]
Usize(usize),
#[non_exhaustive]
F32(f32),
#[non_exhaustive]
F64(f64),
#[non_exhaustive]
Char { value: char, context: HtmlContext },
#[non_exhaustive]
Str {
value: Cow<'static, str>,
context: HtmlContext,
},
#[non_exhaustive]
BoxDyn {
inner: Box<dyn DynViewPart>,
context: HtmlContext,
size_hint: usize,
},
#[non_exhaustive]
BoxSlice {
inner: Box<[ViewPart]>,
size_hint: usize,
},
#[cfg(feature = "http")]
#[non_exhaustive]
StatusCode(StatusCode),
#[cfg(feature = "http")]
#[non_exhaustive]
Headers(Box<HeaderMap>),
}
impl ViewPart {
#[inline]
#[must_use]
pub fn empty() -> Self {
Self::Empty
}
#[must_use]
pub fn is_empty(&self) -> bool {
matches!(self, Self::Empty)
}
#[inline]
pub(crate) const fn unescaped(value: &'static str) -> Self {
Self::Str {
value: Cow::Borrowed(value),
context: HtmlContext::Unescaped,
}
}
pub(crate) fn render(&self, cx: &Cx, f: &mut Formatter<'_>) {
let mut int_buffer = itoa::Buffer::new();
let mut float_buffer = zmij::Buffer::new();
match self {
Self::Empty => {}
Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
Self::I8(inner) => f.write_str(int_buffer.format(*inner)),
Self::I16(inner) => f.write_str(int_buffer.format(*inner)),
Self::I32(inner) => f.write_str(int_buffer.format(*inner)),
Self::I64(inner) => f.write_str(int_buffer.format(*inner)),
Self::I128(inner) => f.write_str(int_buffer.format(*inner)),
Self::Isize(inner) => f.write_str(int_buffer.format(*inner)),
Self::U8(inner) => f.write_str(int_buffer.format(*inner)),
Self::U16(inner) => f.write_str(int_buffer.format(*inner)),
Self::U32(inner) => f.write_str(int_buffer.format(*inner)),
Self::U64(inner) => f.write_str(int_buffer.format(*inner)),
Self::U128(inner) => f.write_str(int_buffer.format(*inner)),
Self::Usize(inner) => f.write_str(int_buffer.format(*inner)),
Self::F32(inner) => f.write_str(float_buffer.format(*inner)),
Self::F64(inner) => f.write_str(float_buffer.format(*inner)),
Self::Char { value, context } => context.writer(f).write_char(*value),
Self::Str { value, context } => context.writer(f).write_str(value),
Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
Self::BoxSlice { inner, .. } => {
for part in inner {
part.render(cx, f);
}
}
#[cfg(feature = "http")]
Self::StatusCode(status_code) => f.record_status_code(*status_code),
#[cfg(feature = "http")]
Self::Headers(headers) => f.record_headers(headers),
}
}
pub(crate) fn size_hint(&self) -> usize {
#[allow(clippy::match_same_arms)]
match self {
Self::Empty => 0,
Self::Bool(_) => 5,
Self::I8(_) => 3,
Self::I16(_) => 4,
Self::I32(_) => 6,
Self::I64(_) => 11,
Self::I128(_) => 21,
Self::Isize(_) => 11,
Self::U8(_) => 2,
Self::U16(_) => 3,
Self::U32(_) => 6,
Self::U64(_) => 11,
Self::U128(_) => 20,
Self::Usize(_) => 11,
Self::F32(_) => 9,
Self::F64(_) => 13,
Self::Char { .. } => 3,
Self::Str { value, context } => match context {
HtmlContext::Unescaped => value.len(),
_ => value.len() + value.len() / 8,
},
Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
#[cfg(feature = "http")]
Self::StatusCode(_) | Self::Headers(_) => 0,
}
}
}
pub trait DynViewPart: 'static + fmt::Debug + Send {
fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
#[inline]
fn size_hint(&self) -> usize {
0
}
fn clone_box(&self) -> Box<dyn DynViewPart>;
}
impl Clone for Box<dyn DynViewPart> {
#[inline]
fn clone(&self) -> Self {
(**self).clone_box()
}
}
#[doc(hidden)]
#[derive(Debug, Default, Clone)]
pub struct ViewParts {
items: SmallVec<[ViewPart; 8]>,
}
impl ViewParts {
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn push_view(&mut self, view: View) -> &mut Self {
self.items.push(view.into_part());
self
}
#[doc(hidden)]
#[inline]
pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
self.items.push(part);
self
}
}
impl From<ViewParts> for ViewPart {
#[inline]
fn from(mut value: ViewParts) -> Self {
match value.items.len() {
0 => ViewPart::Empty,
1 => value.items.pop().unwrap(),
_ => {
let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
ViewPart::BoxSlice {
inner: value.items.into_boxed_slice(),
size_hint,
}
}
}
}
}
macro_rules! impl_push_primitive {
($method:ident, $ty:ty, $variant:ident) => {
#[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
#[inline]
pub fn $method(&mut self, value: $ty) -> &mut Self {
self.parts.items.push(ViewPart::$variant(value));
self
}
};
}
pub struct PartsWriter<'a> {
parts: &'a mut ViewParts,
context: HtmlContext,
}
impl<'a> PartsWriter<'a> {
#[inline]
pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
Self { parts, context }
}
#[inline]
pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
PartsWriter {
parts: self.parts,
context,
}
}
#[inline]
pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
self.parts.items.push(ViewPart::Str {
value: value.into(),
context: self.context,
});
self
}
#[inline]
pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
self.parts.items.push(ViewPart::Str {
value: value.into(),
context: HtmlContext::Unescaped,
});
self
}
#[inline]
pub fn push_char(&mut self, value: char) -> &mut Self {
self.parts.items.push(ViewPart::Char {
value,
context: self.context,
});
self
}
impl_push_primitive!(push_bool, bool, Bool);
impl_push_primitive!(push_i8, i8, I8);
impl_push_primitive!(push_i16, i16, I16);
impl_push_primitive!(push_i32, i32, I32);
impl_push_primitive!(push_i64, i64, I64);
impl_push_primitive!(push_i128, i128, I128);
impl_push_primitive!(push_isize, isize, Isize);
impl_push_primitive!(push_u8, u8, U8);
impl_push_primitive!(push_u16, u16, U16);
impl_push_primitive!(push_u32, u32, U32);
impl_push_primitive!(push_u64, u64, U64);
impl_push_primitive!(push_u128, u128, U128);
impl_push_primitive!(push_usize, usize, Usize);
impl_push_primitive!(push_f32, f32, F32);
impl_push_primitive!(push_f64, f64, F64);
#[inline]
pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
self.parts.items.push(ViewPart::BoxDyn {
size_hint: part.size_hint(),
inner: part,
context: self.context,
});
self
}
#[doc(hidden)]
#[inline]
pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
self.parts.items.push(part);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(build: impl FnOnce(&mut ViewParts)) -> String {
let mut parts = ViewParts::new();
build(&mut parts);
View::new(parts).render(&Cx::default())
}
#[test]
fn empty_view_renders_empty() {
assert_eq!(View::empty().render(&Cx::default()), "");
}
#[test]
fn unescaped_unchecked_renders_verbatim() {
let view = View::unescaped_unchecked("<b>raw</b>");
assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
}
#[test]
fn push_str_seals_the_writer_context() {
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
});
assert_eq!(out, "<b> & \"q\"");
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
});
assert_eq!(out, "<b> & "q"");
}
#[test]
fn push_str_unescaped_bypasses_the_context() {
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
});
assert_eq!(out, "<b>raw</b>");
}
#[test]
fn push_char_seals_the_writer_context() {
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Text).push_char('<');
});
assert_eq!(out, "<");
}
#[test]
#[should_panic(expected = "invalid attribute key")]
fn ident_context_panics_on_forbidden_characters_at_render() {
render(|parts| {
PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
});
}
#[test]
fn push_primitives_render_as_text() {
let out = render(|parts| {
let mut writer = PartsWriter::new(parts, HtmlContext::Text);
writer.push_i32(-42).push_str_unescaped(" ");
writer.push_bool(true).push_str_unescaped(" ");
writer.push_f64(1.5);
});
assert_eq!(out, "-42 true 1.5");
}
#[test]
fn push_view_splices_nested_views() {
let mut inner_parts = ViewParts::new();
PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
let inner = View::new(inner_parts);
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
parts.push_view(inner);
PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
});
assert_eq!(out, "<p>a < b</p>");
}
#[test]
fn size_hint_is_exact_for_unescaped_strings() {
let view = View::unescaped_unchecked("<b>raw</b>");
assert_eq!(view.part.size_hint(), 10);
}
#[cfg(feature = "http")]
mod response {
use http::header::{CACHE_CONTROL, SET_COOKIE};
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use super::*;
use crate::NodeViewParts;
fn push_node(parts: &mut ViewParts, value: impl NodeViewParts) {
value.into_view_parts(
&Cx::default(),
&mut PartsWriter::new(parts, HtmlContext::Text),
);
}
fn push_text(parts: &mut ViewParts, text: &'static str) {
PartsWriter::new(parts, HtmlContext::Text).push_str(text);
}
#[test]
fn status_code_is_recorded_and_renders_nothing() {
let mut parts = ViewParts::new();
push_text(&mut parts, "a");
push_node(&mut parts, StatusCode::NOT_FOUND);
push_text(&mut parts, "b");
let rendered = View::new(parts).render_response(&Cx::default());
assert_eq!(rendered.html, "ab");
assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
assert!(rendered.headers.is_empty());
}
#[test]
fn render_response_without_declarations_is_empty() {
let mut parts = ViewParts::new();
push_text(&mut parts, "a");
let rendered = View::new(parts).render_response(&Cx::default());
assert_eq!(rendered.html, "a");
assert_eq!(rendered.status_code, None);
assert!(rendered.headers.is_empty());
}
#[test]
fn render_discards_declarations() {
let mut parts = ViewParts::new();
push_node(&mut parts, StatusCode::NOT_FOUND);
push_node(
&mut parts,
(CACHE_CONTROL, HeaderValue::from_static("no-store")),
);
push_text(&mut parts, "a");
assert_eq!(View::new(parts).render(&Cx::default()), "a");
}
#[test]
fn first_status_code_wins() {
let mut parts = ViewParts::new();
push_node(&mut parts, StatusCode::NOT_FOUND);
push_node(&mut parts, StatusCode::OK);
let rendered = View::new(parts).render_response(&Cx::default());
assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
}
#[test]
fn first_mention_of_a_header_name_wins() {
let mut parts = ViewParts::new();
push_node(
&mut parts,
(CACHE_CONTROL, HeaderValue::from_static("no-store")),
);
let mut later = HeaderMap::new();
later.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
later.insert(
HeaderName::from_static("x-extra"),
HeaderValue::from_static("1"),
);
push_node(&mut parts, later);
let rendered = View::new(parts).render_response(&Cx::default());
assert_eq!(rendered.headers[CACHE_CONTROL], "no-store");
assert_eq!(rendered.headers["x-extra"], "1");
}
#[test]
fn one_map_keeps_all_values_for_a_name() {
let mut first = HeaderMap::new();
first.append(SET_COOKIE, HeaderValue::from_static("a=1"));
first.append(SET_COOKIE, HeaderValue::from_static("b=2"));
let mut later = HeaderMap::new();
later.insert(SET_COOKIE, HeaderValue::from_static("c=3"));
let mut parts = ViewParts::new();
push_node(&mut parts, first);
push_node(&mut parts, later);
let rendered = View::new(parts).render_response(&Cx::default());
let cookies: Vec<_> = rendered.headers.get_all(SET_COOKIE).iter().collect();
assert_eq!(cookies, ["a=1", "b=2"]);
}
#[test]
fn placement_decides_precedence_across_nested_views() {
let mut inner_parts = ViewParts::new();
push_node(&mut inner_parts, StatusCode::NOT_FOUND);
push_text(&mut inner_parts, "inner");
let inner = View::new(inner_parts);
let mut outer_parts = ViewParts::new();
push_node(&mut outer_parts, StatusCode::FORBIDDEN);
outer_parts.push_view(inner.clone());
let rendered = View::new(outer_parts).render_response(&Cx::default());
assert_eq!(rendered.status_code, Some(StatusCode::FORBIDDEN));
let mut outer_parts = ViewParts::new();
outer_parts.push_view(inner);
push_node(&mut outer_parts, StatusCode::FORBIDDEN);
let rendered = View::new(outer_parts).render_response(&Cx::default());
assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
}
}
}