vize_l1 0.582.0

L1 - the lossless Vue-template surface tree (codename Sinopia, Davinci P2-7)
Documentation
//! Lossless byte-span Vue SFC block splitter for opt-in stage capture.
//! Full product SFC descriptor migration remains #6837.

use vize_l0::{Allocator, Span, Vec};

use super::{Block, Container, ContainerError, ContainerErrorCode, ContainerFormat};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Vue;

mod descriptor;
mod scan;

pub use descriptor::{
    AdmittedDescriptor, DescriptorIssue, DescriptorIssueCode, DescriptorObservation,
    DescriptorOptions, DescriptorRefusal, NativeTemplateFrameNameRefusal, NativeTemplateFrameNames,
    ScriptRole, ScriptView, StyleView, TemplateOnlyDescriptor, TemplateView,
    Vue1DescriptorObservation, Vue1DescriptorRefusal, Vue1TemplateView,
    Vue2DescriptorExpressionPool, Vue2DescriptorObservation, Vue2DescriptorRefusal,
    Vue2RetainedTemplateView, Vue2TemplateView,
};

#[cfg(test)]
pub(crate) use descriptor::vue1::hooks as vue1_entry_hooks;
#[cfg(test)]
pub(crate) use descriptor::vue2::hooks as vue2_entry_hooks;

use scan::{MatchedClose, find_bytes, find_close, find_template_close, read_open};

impl ContainerFormat for Vue {
    fn split<'a>(&self, allocator: &'a Allocator, source: &'a str) -> Container<'a> {
        split_with(allocator, source, |_, _, _, _, _| {})
    }
}

impl Vue {
    /// Observe a single template with default Vue policy and an optional
    /// canonical `lang=html` attribute. The original boundary scanner checks
    /// the complete source without an arena.
    pub fn observe_template_only<'a>(&self, source: &'a str) -> Option<TemplateOnlyDescriptor<'a>> {
        descriptor::observe_template_only(source)
    }

    /// Observe original blocks and descriptor policy in the same splitter pass.
    /// Public capture records cannot be converted into an admitted descriptor.
    pub fn observe_descriptor<'a>(
        &self,
        allocator: &'a Allocator,
        source: &'a str,
        options: DescriptorOptions,
    ) -> DescriptorObservation<'a> {
        descriptor::observe(allocator, source, options)
    }
}

fn split_with<'a>(
    allocator: &'a Allocator,
    source: &'a str,
    mut observe: impl FnMut(usize, &Block<'a>, bool, bool, Option<MatchedClose>),
) -> Container<'a> {
    #[cfg(test)]
    vue1_entry_hooks::splitter_entry();
    #[cfg(test)]
    vue2_entry_hooks::splitter_entry();
    let mut result = Container {
        source,
        blocks: Vec::new_in(&allocator),
        errors: Vec::new_in(&allocator),
    };
    if source.len() > u32::MAX as usize {
        result.errors.push(ContainerError {
            code: ContainerErrorCode::SourceTooLarge,
            offset: 0,
        });
        return result;
    }
    let bytes = source.as_bytes();
    let (mut at, mut template, mut script, mut setup) = (0, false, false, false);
    while at < bytes.len() {
        if bytes.get(at) != Some(&b'<') {
            at += 1;
            continue;
        }
        if bytes
            .get(at..)
            .is_some_and(|rest| rest.starts_with(b"<!--"))
        {
            at = find_bytes(bytes, at + 4, b"-->").map_or(bytes.len(), |end| end + 3);
            continue;
        }
        let open = match read_open(allocator, source, at) {
            Ok(Some(open)) => open,
            Ok(None) => {
                at += 1;
                continue;
            }
            Err(()) => {
                result.errors.push(ContainerError {
                    code: ContainerErrorCode::UnterminatedOpenTag,
                    offset: at as u32,
                });
                break;
            }
        };
        let duplicate = if open.name.eq_ignore_ascii_case("template") {
            let old = template;
            template = true;
            old
        } else if open.name.eq_ignore_ascii_case("script") {
            if open.attrs.iter().any(|attr| attr.name == "setup") {
                let old = setup;
                setup = true;
                old
            } else {
                let old = script;
                script = true;
                old
            }
        } else {
            false
        };
        if duplicate {
            result.errors.push(ContainerError {
                code: ContainerErrorCode::DuplicateBlock,
                offset: at as u32,
            });
        }
        let (content_end, closing, uncertain) = if open.self_closing {
            (open.end, None, false)
        } else if open.name.eq_ignore_ascii_case("template") {
            find_template_close(source, open.end)
        } else {
            find_close(bytes, open.end, open.name).map_or((bytes.len(), None, false), |close| {
                (close.start(), Some(close), false)
            })
        };
        let close_end = if open.self_closing {
            Some(open.end)
        } else {
            closing.map(MatchedClose::end)
        };
        if uncertain {
            result.errors.push(ContainerError {
                code: ContainerErrorCode::UncertainInterpolation,
                offset: at as u32,
            });
        }
        if close_end.is_none() {
            result.errors.push(ContainerError {
                code: ContainerErrorCode::MissingCloseTag,
                offset: at as u32,
            });
        }
        let block = Block {
            name: open.name,
            open_tag: Span::new(at as u32, open.end as u32),
            attrs: open.attrs,
            content: Span::new(open.end as u32, content_end as u32),
            close_tag: close_end.map(|end| Span::new(content_end as u32, end as u32)),
        };
        observe(
            result.blocks.len(),
            &block,
            uncertain,
            open.self_closing,
            closing,
        );
        result.blocks.push(block);
        at = close_end.unwrap_or(bytes.len());
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn splits_blocks_with_authored_byte_spans() {
        let source = "<!-- head -->\n<template lang='pug'>\n  p 日本語\n</template>\n<script setup lang=ts>const x = 1</script>\n<style scoped>.x { color: red }</style>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 3);
        assert_eq!(result.blocks[0].attr("lang").unwrap().value, Some("pug"));
        assert_eq!(result.blocks[0].content.slice(source), "\n  p 日本語\n");
        assert_eq!(result.blocks[1].attr("setup").unwrap().value, None);
        assert_eq!(result.blocks[1].content.slice(source), "const x = 1");
        assert_eq!(
            result.blocks[2].close_tag.unwrap().slice(source),
            "</style>"
        );
    }

    #[test]
    fn nested_template_and_interpolation_do_not_end_root() {
        let source = "<template><template #default><p :x='\"</template>\"'>{{ '</template>' }}</p></template><div>ok</div></template>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 1);
        assert_eq!(
            result.blocks[0].content.slice(source),
            "<template #default><p :x='\"</template>\"'>{{ '</template>' }}</p></template><div>ok</div>"
        );
        assert_eq!(
            result.blocks[0].close_tag.unwrap().slice(source),
            "</template>"
        );
    }

    #[test]
    fn nested_js_braces_do_not_end_interpolation_or_template() {
        let source =
            "<template>{{ ({ x: {} }) ? '</template>' : 'ok' }}</template><style>.ok {}</style>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 2);
        assert_eq!(
            result.blocks[0].content.slice(source),
            "{{ ({ x: {} }) ? '</template>' : 'ok' }}"
        );
        assert_eq!(
            result.blocks[0].close_tag.unwrap().slice(source),
            "</template>"
        );
        assert_eq!(result.blocks[1].content.slice(source), ".ok {}");
    }

    #[test]
    fn js_comment_delimiters_do_not_end_interpolation_or_template() {
        let source = "<template>{{ /* }} <template> */ value }}<div>after</div></template><style>.ok {}</style>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 2);
        assert_eq!(
            result.blocks[0].content.slice(source),
            "{{ /* }} <template> */ value }}<div>after</div>"
        );
        assert_eq!(
            result.blocks[0].close_tag.unwrap().slice(source),
            "</template>"
        );
        assert_eq!(result.blocks[1].content.slice(source), ".ok {}");
    }

    #[test]
    fn js_regex_delimiters_do_not_end_interpolation() {
        let source = "<template>{{ /}} <template>/.test(value) ? count / 2 : 0 }}<div>after</div></template>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 1);
        assert_eq!(
            result.blocks[0].content.slice(source),
            "{{ /}} <template>/.test(value) ? count / 2 : 0 }}<div>after</div>"
        );
    }

    #[test]
    fn ts_non_null_assertion_before_division_keeps_the_template_close() {
        let source = "<template>{{ value! / 2 }}</template><style>.ok {}</style>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 2);
        assert_eq!(result.blocks[0].content.slice(source), "{{ value! / 2 }}");
        assert_eq!(result.blocks[1].content.slice(source), ".ok {}");
    }

    #[test]
    fn js_postfix_increment_before_division_keeps_the_template_close() {
        let source = "<template>{{ count++ / 2 }}</template><style>.ok {}</style>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 2);
        assert_eq!(result.blocks[0].content.slice(source), "{{ count++ / 2 }}");
        assert_eq!(result.blocks[1].content.slice(source), ".ok {}");
    }

    #[test]
    fn adjacent_prefix_and_postfix_operators_keep_division_distinct_from_regex() {
        for (source, content) in [
            ("<template>{{ x + +y / 2 }}</template>", "{{ x + +y / 2 }}"),
            ("<template>{{ x+++y / 2 }}</template>", "{{ x+++y / 2 }}"),
            ("<template>{{ ++x / 2 }}</template>", "{{ ++x / 2 }}"),
            ("<template>{{ x-- / 2 }}</template>", "{{ x-- / 2 }}"),
        ] {
            let allocator = Allocator::default();
            let result = Vue.split(&allocator, source);
            assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
            assert_eq!(result.blocks.len(), 1);
            assert_eq!(result.blocks[0].content.slice(source), content);
        }
    }

    #[test]
    fn nested_template_expression_is_reported_as_uncertain() {
        let source = "<template>{{ `value ${name}` }}</template>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert_eq!(result.blocks.len(), 1);
        assert_eq!(
            result.errors[0].code,
            ContainerErrorCode::UncertainInterpolation
        );
        assert_eq!(result.errors[1].code, ContainerErrorCode::MissingCloseTag);
        assert!(result.blocks[0].close_tag.is_none());
    }

    #[test]
    fn duplicate_and_missing_closer_report_offsets() {
        let source = "<template>x</template><template>y";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert_eq!(result.blocks.len(), 2);
        assert_eq!(result.errors.len(), 2);
        assert_eq!(result.errors[0].code, ContainerErrorCode::DuplicateBlock);
        assert_eq!(result.errors[1].code, ContainerErrorCode::MissingCloseTag);
        assert_eq!(result.blocks[1].content.slice(source), "y");
    }

    #[test]
    fn unclosed_interpolation_is_not_a_trusted_capture_boundary() {
        let source = "<template>{{ '</template>' </template>";
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, source);
        assert!(
            result
                .errors
                .iter()
                .any(|error| error.code == ContainerErrorCode::UncertainInterpolation)
        );
    }

    #[test]
    fn long_closed_interpolation_has_no_artificial_limit() {
        let mut source = vize_l0::String::from("<template>{{ ");
        source.push_str(&"x".repeat(8192));
        source.push_str(" }}</template>");
        let allocator = Allocator::default();
        let result = Vue.split(&allocator, &source);
        assert!(result.errors.is_empty(), "{:?}", result.errors);
        assert_eq!(result.blocks.len(), 1);
        assert_eq!(
            result.blocks[0].close_tag.unwrap().slice(&source),
            "</template>"
        );
    }
}