Skip to main content

topcoat_view/buffer/
part.rs

1use core::fmt;
2
3#[cfg(feature = "http")]
4use http::{HeaderMap, StatusCode};
5use topcoat_core::context::Cx;
6
7use crate::{
8    AttributeCollector, CollectedPart, HtmlContext, HtmlWriter, RegionId, ViewHandle,
9    buffer::ViewBuffer,
10};
11
12/// A boxed view part that writes its output at render time.
13///
14/// Implement this for values whose output is only known when the view
15/// renders, such as resolved asset URLs. The writer passed to
16/// [`render`](Self::render) already carries the [`HtmlContext`] of the
17/// position the part was pushed into, so everything written through it is
18/// escaped or validated for that position.
19pub trait DynViewPart: 'static + fmt::Debug + Send + Sync {
20    /// Writes this part's output into `w`.
21    #[track_caller]
22    fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
23
24    /// Returns an estimate of the number of bytes this part will write.
25    ///
26    /// Used to pre-allocate the output buffer, so aim for a close estimate. A
27    /// slight over-estimate is usually preferable to an under-estimate.
28    #[inline]
29    fn size_hint(&self) -> usize {
30        0
31    }
32}
33
34macro_rules! impl_push_primitive {
35    ($method:ident, $ty:ty, $size_hint:expr) => {
36        #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
37        ///
38        /// Its rendered form contains no character that is significant in any
39        /// HTML context, so no escaping applies.
40        #[inline]
41        pub fn $method(&mut self, value: $ty) -> &mut Self {
42            self.size_hint += $size_hint;
43            self.sink.$method(value);
44            self
45        }
46    };
47}
48
49/// A context-carrying writer over an instruction buffer, created per
50/// position.
51///
52/// The `view!` macro creates a `PartsWriter` for each dynamic position it
53/// fills and hands it to the matching position trait:
54/// [`NodeViewParts`](crate::NodeViewParts),
55/// [`AttributeValueViewParts`](crate::AttributeValueViewParts),
56/// [`AttributeKeyViewParts`](crate::AttributeKeyViewParts),
57/// [`ElementNameViewParts`](crate::ElementNameViewParts), or
58/// [`AttributeViewParts`](crate::AttributeViewParts).
59///
60/// Implementations of those traits make a value renderable by pushing it
61/// through the `push_*` methods, which seal the pushed text with the
62/// [`HtmlContext`] of the position so rendering escapes or validates it
63/// correctly, or by delegating to another implementation of the same
64/// position trait. The `push_*_unescaped` methods are the only way to opt
65/// out of that protection.
66///
67/// The writer also accumulates a size hint: an estimate of the number of
68/// bytes everything pushed so far will write when rendered. The estimate
69/// becomes the built view's size hint, which pre-allocates the output buffer
70/// at render time.
71pub struct PartsWriter<'a> {
72    sink: Sink<'a>,
73    context: HtmlContext,
74    size_hint: usize,
75}
76
77impl<'a> PartsWriter<'a> {
78    /// Creates a writer that seals everything pushed into it with `context`.
79    #[inline]
80    pub(super) fn new(buffer: &'a mut ViewBuffer, context: HtmlContext) -> Self {
81        Self {
82            sink: Sink::Buffer(buffer),
83            context,
84            size_hint: 0,
85        }
86    }
87
88    /// Creates a writer sealing for `context` whose pushes are collected
89    /// into `collector` instead of a buffer.
90    #[inline]
91    pub(crate) fn collecting(
92        collector: &'a mut AttributeCollector,
93        cx: &'a Cx,
94        context: HtmlContext,
95    ) -> Self {
96        Self {
97            sink: Sink::Collector { collector, cx },
98            context,
99            size_hint: 0,
100        }
101    }
102
103    /// Returns the accumulated size hint of everything pushed so far.
104    #[inline]
105    pub(super) fn size_hint(&self) -> usize {
106        self.size_hint
107    }
108
109    /// Runs `f` with this writer sealing for a different context, then
110    /// restores the current context.
111    ///
112    /// In-crate compositions that span more than one position use this to
113    /// transition between the positions they cover, such as
114    /// [`Attribute`](crate::Attribute) moving from a key to a value or
115    /// [`push_comment`](Self::push_comment) sealing a comment body.
116    ///
117    /// This method should remain private to avoid potential XSS footguns.
118    #[inline]
119    pub(crate) fn in_context<R>(
120        &mut self,
121        context: HtmlContext,
122        f: impl FnOnce(&mut Self) -> R,
123    ) -> R {
124        let previous = std::mem::replace(&mut self.context, context);
125        let result = f(self);
126        self.context = previous;
127        result
128    }
129
130    /// Estimates the bytes `value` writes when rendered in `context`.
131    fn str_size_hint(value: &str, context: HtmlContext) -> usize {
132        match context {
133            HtmlContext::Unescaped => value.len(),
134            // Assume some characters escape into multi-byte sequences.
135            _ => value.len() + value.len() / 8,
136        }
137    }
138
139    /// Appends a borrowed string, sealed with this writer's context.
140    #[inline]
141    pub fn push_str(&mut self, value: &str) -> &mut Self {
142        self.size_hint += Self::str_size_hint(value, self.context);
143        self.sink.push_str(value, self.context);
144        self
145    }
146
147    /// Appends a static string, sealed with this writer's context.
148    #[inline]
149    pub fn push_static_str(&mut self, value: &'static str) -> &mut Self {
150        self.size_hint += Self::str_size_hint(value, self.context);
151        self.sink.push_static_str(value, self.context);
152        self
153    }
154
155    /// Appends a static string held by reference, sealed with this writer's
156    /// context.
157    ///
158    /// Pass `&"..."`, which Rust promotes to a reference into the binary's
159    /// read-only data. The string stays out of the buffer's constants, so
160    /// prefer this over [`push_static_str`](Self::push_static_str) whenever
161    /// the string is written as a literal.
162    #[inline]
163    pub fn push_promoted_str(&mut self, value: &'static &'static str) -> &mut Self {
164        self.size_hint += Self::str_size_hint(value, self.context);
165        self.sink.push_promoted_str(value, self.context);
166        self
167    }
168
169    /// Appends an owned string, sealed with this writer's context.
170    #[inline]
171    pub fn push_string(&mut self, value: String) -> &mut Self {
172        self.size_hint += Self::str_size_hint(&value, self.context);
173        self.sink.push_string(value, self.context);
174        self
175    }
176
177    /// Appends a borrowed string that renders verbatim, bypassing this
178    /// writer's context.
179    ///
180    /// Use this only for trusted markup. Passing untrusted input defeats the
181    /// runtime's escaping and can lead to XSS vulnerabilities.
182    #[inline]
183    pub fn push_str_unescaped(&mut self, value: &str) -> &mut Self {
184        self.size_hint += value.len();
185        self.sink.push_str(value, HtmlContext::Unescaped);
186        self
187    }
188
189    /// Appends a static string that renders verbatim, bypassing this
190    /// writer's context.
191    ///
192    /// Use this only for trusted markup. Passing untrusted input defeats the
193    /// runtime's escaping and can lead to XSS vulnerabilities.
194    #[inline]
195    pub fn push_static_str_unescaped(&mut self, value: &'static str) -> &mut Self {
196        self.size_hint += value.len();
197        self.sink.push_static_str(value, HtmlContext::Unescaped);
198        self
199    }
200
201    /// Appends a static string held by reference that renders verbatim,
202    /// bypassing this writer's context.
203    ///
204    /// Pass `&"..."`, which Rust promotes to a reference into the binary's
205    /// read-only data. The string stays out of the buffer's constants, so
206    /// prefer this over
207    /// [`push_static_str_unescaped`](Self::push_static_str_unescaped)
208    /// whenever the string is written as a literal.
209    ///
210    /// Use this only for trusted markup. Passing untrusted input defeats the
211    /// runtime's escaping and can lead to XSS vulnerabilities.
212    #[inline]
213    pub fn push_promoted_str_unescaped(&mut self, value: &'static &'static str) -> &mut Self {
214        self.size_hint += value.len();
215        self.sink.push_promoted_str(value, HtmlContext::Unescaped);
216        self
217    }
218
219    /// Appends an owned string that renders verbatim, bypassing this
220    /// writer's context.
221    ///
222    /// Use this only for trusted markup. Passing untrusted input defeats the
223    /// runtime's escaping and can lead to XSS vulnerabilities.
224    #[inline]
225    pub fn push_string_unescaped(&mut self, value: String) -> &mut Self {
226        self.size_hint += value.len();
227        self.sink.push_string(value, HtmlContext::Unescaped);
228        self
229    }
230
231    /// Appends an HTML comment whose body is built through `build`.
232    ///
233    /// The `<!-- ` and ` -->` delimiters are written verbatim, while the
234    /// writer handed to `build` seals everything pushed into it for the
235    /// [`Comment`](HtmlContext::Comment) context. Because that context
236    /// escapes `>`, the body can never contain `-->` and terminate the
237    /// comment, so a marker can be built from untrusted data with
238    /// [`push_str`](Self::push_str) and no separate escaping step.
239    ///
240    /// # Panics
241    ///
242    /// Panics if used in a non-text HTML context.
243    #[inline]
244    pub fn push_comment(&mut self, build: impl FnOnce(&mut PartsWriter<'_>)) -> &mut Self {
245        assert!(
246            self.context == HtmlContext::Text,
247            "tried to push comment in html context {:?}",
248            self.context,
249        );
250        self.push_promoted_str_unescaped(&"<!--");
251        self.in_context(HtmlContext::Comment, build);
252        self.push_promoted_str_unescaped(&"-->");
253        self
254    }
255
256    /// Appends a character, sealed with this writer's context.
257    #[inline]
258    pub fn push_char(&mut self, value: char) -> &mut Self {
259        // One to four UTF-8 bytes, or an escape sequence.
260        self.size_hint += 3;
261        self.sink.push_char(value, self.context);
262        self
263    }
264
265    // Each numeric size hint is the midpoint, rounded up, between the
266    // shortest and widest output the type can render, including the leading
267    // `-` for signed types (`isize`/`usize` assume a 64-bit target). A
268    // float's rendered width is unbounded for extreme magnitudes, so the
269    // upper end is the shortest round-trip form of a typical value.
270
271    impl_push_primitive!(push_bool, bool, 5);
272    impl_push_primitive!(push_i8, i8, 3);
273    impl_push_primitive!(push_i16, i16, 4);
274    impl_push_primitive!(push_i32, i32, 6);
275    impl_push_primitive!(push_i64, i64, 11);
276    impl_push_primitive!(push_i128, i128, 21);
277    impl_push_primitive!(push_isize, isize, 11);
278    impl_push_primitive!(push_u8, u8, 2);
279    impl_push_primitive!(push_u16, u16, 3);
280    impl_push_primitive!(push_u32, u32, 6);
281    impl_push_primitive!(push_u64, u64, 11);
282    impl_push_primitive!(push_u128, u128, 20);
283    impl_push_primitive!(push_usize, usize, 11);
284    impl_push_primitive!(push_f32, f32, 9);
285    impl_push_primitive!(push_f64, f64, 13);
286
287    /// Appends the start of the region `region`.
288    ///
289    /// # Panics
290    ///
291    /// Panics if used in a non-text HTML context.
292    #[inline]
293    pub(crate) fn push_region_start(&mut self, region: RegionId) -> &mut Self {
294        assert!(
295            self.context == HtmlContext::Text,
296            "tried to push region start in html context {:?}",
297            self.context,
298        );
299        // A rendered boundary is a fixed frame around the id's digits.
300        self.size_hint += 42;
301        self.sink.push_region_start(region);
302        self
303    }
304
305    /// Appends the end of the region `region`.
306    ///
307    /// # Panics
308    ///
309    /// Panics if used in a non-text HTML context.
310    #[inline]
311    pub(crate) fn push_region_end(&mut self, region: RegionId) -> &mut Self {
312        assert!(
313            self.context == HtmlContext::Text,
314            "tried to push region end in html context {:?}",
315            self.context,
316        );
317        // A rendered boundary is a fixed frame around the id's digits.
318        self.size_hint += 40;
319        self.sink.push_region_end(region);
320        self
321    }
322
323    /// Appends a part that writes its output at render time, sealed with
324    /// this writer's context.
325    #[inline]
326    pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
327        self.size_hint += part.size_hint();
328        self.sink.push_dyn(part, self.context);
329        self
330    }
331
332    /// Appends a nested view.
333    ///
334    /// The view's content was already sealed with the contexts it was built
335    /// for; this writer's context does not apply. The view's size hint joins
336    /// this writer's, so a view spliced twice counts its output twice.
337    ///
338    /// # Panics
339    ///
340    /// Panics if the view was built in a different, still building buffer.
341    #[inline]
342    pub fn push_view_handle(&mut self, handle: ViewHandle) -> &mut Self {
343        self.size_hint += handle.size_hint();
344        self.sink.push_view(handle);
345        self
346    }
347
348    /// Records a response status code; renders no content.
349    #[cfg(feature = "http")]
350    #[inline]
351    pub fn push_status_code(&mut self, status_code: StatusCode) -> &mut Self {
352        self.sink.push_status_code(status_code);
353        self
354    }
355
356    /// Records response headers; renders no content.
357    #[cfg(feature = "http")]
358    #[inline]
359    pub fn push_headers(&mut self, headers: HeaderMap) -> &mut Self {
360        self.sink.push_headers(headers);
361        self
362    }
363}
364
365macro_rules! impl_sink_primitive {
366    ($method:ident, $ty:ty, $part:expr) => {
367        #[inline]
368        fn $method(&mut self, value: $ty) {
369            match self {
370                Self::Buffer(buffer) => buffer.$method(value),
371                Self::Collector { collector, cx } => collector.push(cx, $part(value)),
372            }
373        }
374    };
375}
376
377/// Where a writer's pushes go.
378enum Sink<'a> {
379    /// A view buffer under construction.
380    Buffer(&'a mut ViewBuffer),
381    /// The collector capturing one attribute key or value, with the
382    /// context it renders parts under.
383    Collector {
384        collector: &'a mut AttributeCollector,
385        cx: &'a Cx,
386    },
387}
388
389impl Sink<'_> {
390    #[inline]
391    fn push_str(&mut self, value: &str, context: HtmlContext) {
392        match self {
393            Self::Buffer(buffer) => buffer.push_str(value, context),
394            Self::Collector { collector, cx } => collector.push_str(cx, value, context),
395        }
396    }
397
398    #[inline]
399    fn push_static_str(&mut self, value: &'static str, context: HtmlContext) {
400        match self {
401            Self::Buffer(buffer) => buffer.push_static_str(value, context),
402            Self::Collector { collector, cx } => {
403                collector.push(cx, CollectedPart::StaticStr { value, context });
404            }
405        }
406    }
407
408    #[inline]
409    fn push_promoted_str(&mut self, value: &'static &'static str, context: HtmlContext) {
410        match self {
411            Self::Buffer(buffer) => buffer.push_promoted_str(value, context),
412            Self::Collector { collector, cx } => {
413                collector.push(cx, CollectedPart::PromotedStr { value, context });
414            }
415        }
416    }
417
418    #[inline]
419    fn push_string(&mut self, value: String, context: HtmlContext) {
420        match self {
421            Self::Buffer(buffer) => buffer.push_string(value, context),
422            Self::Collector { collector, cx } => {
423                collector.push(cx, CollectedPart::String { value, context });
424            }
425        }
426    }
427
428    #[inline]
429    fn push_char(&mut self, value: char, context: HtmlContext) {
430        match self {
431            Self::Buffer(buffer) => buffer.push_char(value, context),
432            Self::Collector { collector, cx } => {
433                collector.push(cx, CollectedPart::Char { value, context });
434            }
435        }
436    }
437
438    impl_sink_primitive!(push_bool, bool, CollectedPart::Bool);
439    impl_sink_primitive!(push_i8, i8, |value| CollectedPart::Int(i128::from(value)));
440    impl_sink_primitive!(push_i16, i16, |value| CollectedPart::Int(i128::from(value)));
441    impl_sink_primitive!(push_i32, i32, |value| CollectedPart::Int(i128::from(value)));
442    impl_sink_primitive!(push_i64, i64, |value| CollectedPart::Int(i128::from(value)));
443    impl_sink_primitive!(push_i128, i128, CollectedPart::Int);
444    impl_sink_primitive!(push_isize, isize, |value| CollectedPart::Int(value as i128));
445    impl_sink_primitive!(push_u8, u8, |value| CollectedPart::Uint(u128::from(value)));
446    impl_sink_primitive!(push_u16, u16, |value| CollectedPart::Uint(u128::from(
447        value
448    )));
449    impl_sink_primitive!(push_u32, u32, |value| CollectedPart::Uint(u128::from(
450        value
451    )));
452    impl_sink_primitive!(push_u64, u64, |value| CollectedPart::Uint(u128::from(
453        value
454    )));
455    impl_sink_primitive!(push_u128, u128, CollectedPart::Uint);
456    impl_sink_primitive!(push_usize, usize, |value| CollectedPart::Uint(
457        value as u128
458    ));
459    impl_sink_primitive!(push_f32, f32, CollectedPart::F32);
460    impl_sink_primitive!(push_f64, f64, CollectedPart::F64);
461
462    #[inline]
463    fn push_region_start(&mut self, region: RegionId) {
464        match self {
465            Self::Buffer(buffer) => buffer.push_region_start(region),
466            Self::Collector { .. } => panic!("tried to push a region into an attribute"),
467        }
468    }
469
470    #[inline]
471    fn push_region_end(&mut self, region: RegionId) {
472        match self {
473            Self::Buffer(buffer) => buffer.push_region_end(region),
474            Self::Collector { .. } => panic!("tried to push a region into an attribute"),
475        }
476    }
477
478    #[inline]
479    fn push_dyn(&mut self, part: Box<dyn DynViewPart>, context: HtmlContext) {
480        match self {
481            Self::Buffer(buffer) => buffer.push_dyn(part, context),
482            Self::Collector { collector, cx } => {
483                collector.push(cx, CollectedPart::Dyn { part, context });
484            }
485        }
486    }
487
488    #[inline]
489    fn push_view(&mut self, handle: ViewHandle) {
490        match self {
491            Self::Buffer(buffer) => buffer.push_view(handle),
492            Self::Collector { collector, cx } => collector.push(cx, CollectedPart::View(handle)),
493        }
494    }
495
496    /// Records a status code; a collected attribute has nowhere to keep one.
497    #[cfg(feature = "http")]
498    #[inline]
499    fn push_status_code(&mut self, status_code: StatusCode) {
500        if let Self::Buffer(buffer) = self {
501            buffer.push_status_code(status_code);
502        }
503    }
504
505    /// Records headers; a collected attribute has nowhere to keep them.
506    #[cfg(feature = "http")]
507    #[inline]
508    fn push_headers(&mut self, headers: HeaderMap) {
509        if let Self::Buffer(buffer) = self {
510            buffer.push_headers(headers);
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    /// Builds a view through a writer sealed with `context` and renders it.
520    fn render_with(context: HtmlContext, f: impl FnOnce(&mut PartsWriter<'_>)) -> String {
521        ViewBuffer::build(|parts| parts.in_context(context, f)).render(&Cx::default())
522    }
523
524    #[test]
525    fn push_str_seals_the_writer_context() {
526        let out = render_with(HtmlContext::Text, |w| {
527            w.push_str("<b> & \"q\"");
528        });
529        assert_eq!(out, "&lt;b&gt; &amp; \"q\"");
530
531        let out = render_with(HtmlContext::AttributeValue, |w| {
532            w.push_str("<b> & \"q\"");
533        });
534        assert_eq!(out, "<b> &amp; &quot;q&quot;");
535    }
536
537    #[test]
538    fn push_str_unescaped_bypasses_the_context() {
539        let out = render_with(HtmlContext::Text, |w| {
540            w.push_str_unescaped("<b>raw</b>");
541        });
542        assert_eq!(out, "<b>raw</b>");
543    }
544
545    #[test]
546    fn push_promoted_str_seals_the_writer_context() {
547        let out = render_with(HtmlContext::Text, |w| {
548            w.push_promoted_str(&"<b> & \"q\"");
549        });
550        assert_eq!(out, "&lt;b&gt; &amp; \"q\"");
551
552        let out = render_with(HtmlContext::AttributeValue, |w| {
553            w.push_promoted_str(&"<b> & \"q\"");
554        });
555        assert_eq!(out, "<b> &amp; &quot;q&quot;");
556    }
557
558    #[test]
559    fn push_promoted_str_unescaped_bypasses_the_context() {
560        let out = render_with(HtmlContext::Text, |w| {
561            w.push_promoted_str_unescaped(&"<b>raw</b>");
562        });
563        assert_eq!(out, "<b>raw</b>");
564    }
565
566    #[test]
567    fn push_promoted_str_skips_empty_strings() {
568        let out = render_with(HtmlContext::Text, |w| {
569            w.push_promoted_str(&"a").push_promoted_str(&"");
570            w.push_promoted_str_unescaped(&"").push_promoted_str(&"b");
571        });
572        assert_eq!(out, "ab");
573    }
574
575    #[test]
576    fn push_char_seals_the_writer_context() {
577        let out = render_with(HtmlContext::Text, |w| {
578            w.push_char('<');
579        });
580        assert_eq!(out, "&lt;");
581    }
582
583    #[test]
584    #[should_panic(expected = "invalid attribute key")]
585    fn ident_context_panics_on_forbidden_characters_at_render() {
586        render_with(HtmlContext::AttributeKey, |w| {
587            w.push_str("on click");
588        });
589    }
590
591    #[test]
592    fn push_primitives_render_as_text() {
593        let out = render_with(HtmlContext::Text, |w| {
594            w.push_i32(-42).push_str_unescaped(" ");
595            w.push_bool(true).push_str_unescaped(" ");
596            w.push_f64(1.5).push_str_unescaped(" ");
597            w.push_i128(-1 << 100).push_str_unescaped(" ");
598            w.push_u128(1 << 100);
599        });
600        assert_eq!(
601            out,
602            "-42 true 1.5 -1267650600228229401496703205376 1267650600228229401496703205376"
603        );
604    }
605}