Skip to main content

velox_sfc/
sfc.rs

1use pest::Parser;
2use pest::error::ErrorVariant;
3use pest::iterators::Pair;
4
5#[derive(pest_derive::Parser)]
6#[grammar = "grammar.pest"]
7struct SfcParser;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct Attr {
11    pub name: String,
12    pub value: Option<String>, // boolean attrs allowed, e.g., `scoped` or `setup`
13}
14
15#[derive(Debug, Clone, PartialEq, Default)]
16pub struct TemplateBlock {
17    pub attrs: Vec<Attr>,
18    pub content: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Default)]
22pub struct ScriptBlock {
23    pub attrs: Vec<Attr>,
24    pub content: String,
25    pub setup: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Default)]
29pub struct StyleBlock {
30    pub attrs: Vec<Attr>,
31    pub content: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Default)]
35pub struct Sfc {
36    pub template: Option<TemplateBlock>,
37    pub script_setup: Option<ScriptBlock>,
38    pub script: Option<ScriptBlock>,
39    pub style: Option<StyleBlock>,
40    /// Non-fatal template diagnostics collected during parsing: structural
41    /// leniency notes (unclosed/unmatched tags) and `unknown component`
42    /// warnings for PascalCase tags that are not registered component
43    /// imports.
44    ///
45    /// Computed by the shared template-diagnostics channel
46    /// ([`crate::template_parse::parse_template`]) — the same computation the
47    /// compile path prints to stderr — so the two surfaces cannot diverge.
48    /// `parse_sfc` stores warnings for programmatic consumers but never
49    /// prints them, so users never see a warning twice.
50    pub warnings: Vec<String>,
51}
52
53/// Format a Pest parse error into a human-readable message with line number,
54/// column, a source excerpt with a `^` caret, an explanatory message, and an
55/// actionable suggestion where one can be inferred.
56fn format_pest_error(err: pest::error::Error<Rule>, source: &str) -> String {
57    // Pest reports either a single position or a span; use the span start so the
58    // caret points at the first offending character.
59    let (line, column) = match err.line_col {
60        pest::error::LineColLocation::Pos((l, c)) => (l, c),
61        pest::error::LineColLocation::Span((l, c), _) => (l, c),
62    };
63
64    // Determine what kind of parsing failure occurred and produce a
65    // human-readable summary.
66    let mut expected: Vec<String> = Vec::new();
67    let mut unexpected: Vec<String> = Vec::new();
68    let description = match &err.variant {
69        ErrorVariant::ParsingError {
70            positives,
71            negatives,
72        } => {
73            expected = positives.iter().map(rule_to_block_name).collect::<Vec<_>>();
74            unexpected = negatives.iter().map(rule_to_block_name).collect::<Vec<_>>();
75
76            if expected.is_empty() {
77                "unexpected token found while parsing the SFC".to_string()
78            } else {
79                let unexpected_str = if unexpected.is_empty() {
80                    "an unexpected token".to_string()
81                } else {
82                    unexpected.join(", ")
83                };
84                format!(
85                    "expected {} but found {}",
86                    expected.join(", "),
87                    unexpected_str
88                )
89            }
90        }
91        ErrorVariant::CustomError { message } => message.clone(),
92    };
93
94    // Try to infer which block the error falls in by examining the source
95    // up to the error position.
96    let block_context = infer_block_context(source, line);
97    let message = format!("{description} while parsing the {block_context} block");
98
99    // Build an actionable suggestion from the failure.
100    let suggestion = suggest_pest_error(&expected, &unexpected, &block_context);
101
102    crate::diagnostic::render_parse_error(source, line, column, 1, &message, suggestion.as_deref())
103}
104
105/// Heuristically produce a helpful `help:` line for common SFC mistakes, based
106/// on what the grammar expected, what was found, and which block broke.
107fn suggest_pest_error(expected: &[String], unexpected: &[String], block: &str) -> Option<String> {
108    let end_of_input = expected
109        .iter()
110        .any(|e| e.contains("end of input") || e.contains("SFC file"));
111    let unexp_join = unexpected.join(" ");
112
113    if end_of_input {
114        return Some(format!(
115            "the {block} block may be missing its closing tag (e.g. </{block}>), or a block was never opened"
116        ));
117    }
118
119    if block != "top-level" {
120        // We are inside a block; the likely fixes are structural.
121        return Some(format!(
122            "check the {block} tags and attribute quotes near this line; \
123             every value after '=' should be wrapped in \"double\" or 'single' quotes"
124        ));
125    }
126
127    if unexp_join.contains("end of input") {
128        return Some(
129            "the file ended before a block was closed — add the missing </template>, </script>, or </style>".to_string(),
130        );
131    }
132
133    if expected.iter().any(|e| e.contains("<template")) {
134        return Some(
135            "after a <template> block you need a <script setup> block to define component state and logic"
136                .to_string(),
137        );
138    }
139
140    None
141}
142
143/// Map a Pest `Rule` to a human-readable name (block-level where possible).
144fn rule_to_block_name(rule: &Rule) -> String {
145    match rule {
146        Rule::template | Rule::template_open | Rule::template_body | Rule::nested_template => {
147            "<template>".to_string()
148        }
149        // A nested `<template v-slot:foo>` is still a template block as far as a
150        // syntax error inside it is concerned, and it is the only place a slot
151        // binding can appear, so a name here has to say which.
152        Rule::slot_attr => "slot binding (v-slot:name or #name)".to_string(),
153        Rule::slot_name => "slot name".to_string(),
154        Rule::script | Rule::script_open | Rule::script_body => "<script>".to_string(),
155        Rule::style | Rule::style_open | Rule::style_body => "<style>".to_string(),
156        Rule::attribute => "attribute".to_string(),
157        Rule::ident => "identifier".to_string(),
158        Rule::quoted | Rule::dq | Rule::sq => "quoted value".to_string(),
159        Rule::block => "SFC block (<template>, <script>, or <style>)".to_string(),
160        Rule::file => "SFC file".to_string(),
161        Rule::WS => "whitespace".to_string(),
162        Rule::EOI => "end of input".to_string(),
163    }
164}
165
166/// Given the full source text and a line number, try to infer which SFC block
167/// the error occurred in by scanning for the most recent block-opening tag
168/// before that line.
169fn infer_block_context(source: &str, error_line: usize) -> String {
170    let lines: Vec<&str> = source.lines().collect();
171    let mut last_block = "top-level";
172
173    for (idx, line) in lines.iter().enumerate() {
174        let ln = idx + 1;
175        if ln > error_line {
176            break;
177        }
178        let trimmed = line.trim();
179        if trimmed.starts_with("<template") {
180            last_block = "template";
181        } else if trimmed.starts_with("<script") {
182            last_block = "script";
183        } else if trimmed.starts_with("<style") {
184            last_block = "style";
185        } else if trimmed.starts_with("</template")
186            || trimmed.starts_with("</script")
187            || trimmed.starts_with("</style")
188        {
189            last_block = "top-level";
190        }
191    }
192
193    last_block.to_string()
194}
195
196/// How many literal `<template` openers one SFC may contain before `parse_sfc`
197/// refuses the file, bounding the pest grammar's own recursion.
198///
199/// This is deliberately NOT [`crate::template_parse::MAX_TEMPLATE_DEPTH`]. That
200/// constant bounds the hand-written element builder's open-element `Vec`, which
201/// is a different stack in a different parser — reached only after pest has
202/// already returned. Nothing bounded this one: `grammar.pest:51-52` spells the
203/// nesting as
204///
205/// ```text
206/// nested_template = { template_open ~ template_body ~ "</template>" }
207/// template_body  = @{ (nested_template | !"</template>" ~ ANY)* }
208/// ```
209///
210/// — mutual recursion with no ceiling of its own. A file made of nothing but
211/// `<template` openers drives one recursive `template_body` frame per opener
212/// with nothing to stop it, so the ceiling has to live OUTSIDE the grammar: pest
213/// offers no way to parameterise a rule's depth.
214///
215/// 256 matches `MAX_TEMPLATE_DEPTH` and borrows its rationale (see that
216/// constant's doc): several times deeper than any hand-written template needs,
217/// so reaching this means the input is machine-generated or malformed rather
218/// than merely elaborate.
219///
220/// # Why counting occurrences is enough
221///
222/// Every level of `nested_template` consumes a distinct literal `<template` —
223/// that is what `template_open` starts with. So the number of those substrings in
224/// the source is an UPPER BOUND on the grammar's real recursion depth: this
225/// guard can never wave a recursion bomb through. It can, however, reject a file
226/// whose count is inflated by `<template` text the grammar would not open on
227/// (inside an attribute value, a comment, or a `<script>` body holding a code
228/// sample). This scan makes no attempt to exclude those, because proving a
229/// position is inert requires modelling the grammar's own state, and getting
230/// that wrong in a guard whose only job is to be an upper bound turns the guard
231/// into the hazard. The trade is explicit: a file carrying more than 256
232/// `<template` strings anywhere is rejected with an actionable message. Any such
233/// file is already pathological. One exclusion IS safe and IS made, in
234/// `nested_template_openers`: a `<template` not followed by whitespace or `>`
235/// cannot be what `template_open` matches, so a `<templates>` tag is not counted.
236pub const MAX_NESTED_TEMPLATE_DEPTH: usize = 256;
237
238/// Count the literal `<template` openers the grammar could recurse on, and the
239/// byte offset of the first one past [`MAX_NESTED_TEMPLATE_DEPTH`]. See that
240/// constant for why the raw count is the right thing to compare, and what it
241/// deliberately does not try to exclude.
242///
243/// Returns `(count, offending_offset)`, with the offset `0` when nothing
244/// exceeds the limit — no caller reports a position it did not fail on.
245fn nested_template_openers(source: &str) -> (usize, usize) {
246    const OPEN: &str = "<template";
247    // `template_open` is `"<template" ~ (WS+ ~ (slot_attr | attribute))* ~ WS*
248    // ~ ">"`, so the character after the literal is either one of the grammar's
249    // four whitespace characters or the `>` itself — never anything else. That
250    // is exactly what tells a nested block from `<templates>`, and matching it
251    // here means the count is the number of things the grammar can ACTUALLY
252    // open on rather than the number of strings that start the same way. It
253    // still only ever REDUCES the count, so the upper-bound property that makes
254    // this a sound guard is untouched.
255    const WS: [char; 4] = [' ', '\t', '\r', '\n'];
256    let mut count = 0usize;
257    let mut offending_offset = 0usize;
258    // `char_indices`, not a byte counter: `source[i..]` panics if `i` lands in
259    // the middle of a multi-byte character, and a `.vx` file may well contain
260    // one. Every position yielded here is a character boundary. Once
261    // `starts_with(OPEN)` holds, `i + OPEN.len()` is a boundary too — ASCII bytes
262    // never occur inside a multi-byte sequence — so the lookahead below is safe.
263    for (i, _) in source.char_indices() {
264        if !source[i..].starts_with(OPEN) {
265            continue;
266        }
267        let opens_a_block = source[i + OPEN.len()..]
268            .chars()
269            .next()
270            .is_some_and(|after| after == '>' || WS.contains(&after));
271        if !opens_a_block {
272            continue;
273        }
274        // The (MAX + 1)th opener is the first one past the ceiling. It is not
275        // necessarily the last, and byte 0 is never it: every SFC opens with
276        // a top-level `<template>`, so pointing a reader there would send
277        // them to a tag they cannot change.
278        if count == MAX_NESTED_TEMPLATE_DEPTH {
279            offending_offset = i;
280        }
281        count += 1;
282    }
283    (count, offending_offset)
284}
285
286/// Refuse a file whose `<template` count exceeds
287/// [`MAX_NESTED_TEMPLATE_DEPTH`], before pest is handed it.
288fn check_nested_template_depth(source: &str) -> Result<(), String> {
289    let (openers, offending_offset) = nested_template_openers(source);
290    if openers <= MAX_NESTED_TEMPLATE_DEPTH {
291        return Ok(());
292    }
293    let (line, col) = crate::diagnostic::line_col_at(source, offending_offset);
294    Err(crate::diagnostic::render_parse_error(
295        source,
296        line,
297        col,
298        "<template".len(),
299        &format!(
300            "{openers} nested `<template` blocks exceed the limit of \
301             {MAX_NESTED_TEMPLATE_DEPTH}"
302        ),
303        Some(
304            "nest fewer than 256 `<template` blocks deep, or flatten the markup — \
305             a `<template v-slot:name>` block is the only thing that nests, so this \
306             means a slot-fragment tree far deeper than any real template",
307        ),
308    ))
309}
310
311pub fn parse_sfc(source: &str) -> Result<Sfc, String> {
312    let mut sfc = Sfc::default();
313
314    // BEFORE handing the source to pest: the grammar's `template_body` /
315    // `nested_template` pair recurses once per `<template` with no ceiling of
316    // its own, so the bound has to be enforced out here. See
317    // `MAX_NESTED_TEMPLATE_DEPTH`.
318    check_nested_template_depth(source)?;
319
320    // Parse the root and immediately descend into the `file` node.
321    let mut pairs =
322        SfcParser::parse(Rule::file, source).map_err(|e| format_pest_error(e, source))?;
323    let file = pairs
324        .next()
325        .ok_or_else(|| "SFC parse error: input is empty".to_string())?;
326    debug_assert!(file.as_rule() == Rule::file);
327
328    // Walk children of `file`: they will be `block` nodes (and nothing else,
329    // since WS is a silent rule in the grammar).
330    for node in file.into_inner() {
331        match node.as_rule() {
332            Rule::block => {
333                for inner in node.into_inner() {
334                    consume_top_level(inner, &mut sfc);
335                }
336            }
337            // (Defensive: in case grammar changes and blocks appear directly)
338            Rule::template | Rule::script | Rule::style => {
339                consume_top_level(node, &mut sfc);
340            }
341            _ => {}
342        }
343    }
344
345    sfc.warnings = template_warnings(&sfc);
346
347    Ok(sfc)
348}
349
350/// Collect non-fatal template diagnostics for a parsed SFC.
351///
352/// Warnings come from the shared template-diagnostics channel
353/// ([`crate::template_parse::parse_template`]), so the stored messages are
354/// identical to the ones the compile path
355/// (`template_codegen::compile_template_to_rs_full`) prints to stderr.
356///
357/// Known components are the imports declared in `<script setup>`, mirroring
358/// how `ComponentResolver` is populated during a build; imports in a plain
359/// `<script>` block do not register components (the compile path treats such
360/// tags as unknown too).
361///
362/// Hard template errors (e.g. an unclosed `{{` interpolation) are not
363/// warnings — they surface as `Err` from the compile path — so this function
364/// returns no warnings for them, leaving `parse_sfc`'s block-splitting
365/// semantics unchanged.
366fn template_warnings(sfc: &Sfc) -> Vec<String> {
367    let Some(template) = &sfc.template else {
368        return Vec::new();
369    };
370    let mut known: Vec<String> = Vec::new();
371    if let Some(script_setup) = &sfc.script_setup {
372        let mut resolver =
373            crate::component_resolver::ComponentResolver::new(std::path::PathBuf::from("."));
374        resolver.parse_imports(&script_setup.content);
375        known.extend(resolver.component_names());
376    }
377    let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
378    match crate::template_parse::parse_template(&template.content, &known_refs) {
379        Ok(diag) => diag.warnings,
380        Err(_) => Vec::new(),
381    }
382}
383
384fn consume_top_level(node: Pair<Rule>, sfc: &mut Sfc) {
385    match node.as_rule() {
386        Rule::template => {
387            let (attrs, content) = parse_template(node);
388            sfc.template = Some(TemplateBlock { attrs, content });
389        }
390        Rule::script => {
391            let (attrs, content) = parse_script(node);
392            let setup = has_bool_attr(&attrs, "setup");
393            let sb = ScriptBlock {
394                attrs,
395                content,
396                setup,
397            };
398            if setup {
399                sfc.script_setup = Some(sb);
400            } else {
401                sfc.script = Some(sb);
402            }
403        }
404        Rule::style => {
405            let (attrs, content) = parse_style(node);
406            sfc.style = Some(StyleBlock { attrs, content });
407        }
408        _ => {}
409    }
410}
411
412fn parse_template(tpl: Pair<Rule>) -> (Vec<Attr>, String) {
413    let mut attrs = Vec::new();
414    let mut content = String::new();
415
416    for p in tpl.into_inner() {
417        match p.as_rule() {
418            Rule::template_open => {
419                // attributes are direct children of *_open
420                for a in p.into_inner() {
421                    if a.as_rule() == Rule::attribute {
422                        attrs.push(parse_attr(a));
423                    }
424                }
425            }
426            Rule::template_body => content = p.as_str().to_string(),
427            _ => {}
428        }
429    }
430    (attrs, content)
431}
432
433fn parse_script(scr: Pair<Rule>) -> (Vec<Attr>, String) {
434    let mut attrs = Vec::new();
435    let mut content = String::new();
436
437    for p in scr.into_inner() {
438        match p.as_rule() {
439            Rule::script_open => {
440                for a in p.into_inner() {
441                    if a.as_rule() == Rule::attribute {
442                        attrs.push(parse_attr(a));
443                    }
444                }
445            }
446            Rule::script_body => content = p.as_str().to_string(),
447            _ => {}
448        }
449    }
450    (attrs, content)
451}
452
453fn parse_style(sty: Pair<Rule>) -> (Vec<Attr>, String) {
454    let mut attrs = Vec::new();
455    let mut content = String::new();
456
457    for p in sty.into_inner() {
458        match p.as_rule() {
459            Rule::style_open => {
460                for a in p.into_inner() {
461                    if a.as_rule() == Rule::attribute {
462                        attrs.push(parse_attr(a));
463                    }
464                }
465            }
466            Rule::style_body => content = p.as_str().to_string(),
467            _ => {}
468        }
469    }
470    (attrs, content)
471}
472
473fn parse_attr(attr: Pair<Rule>) -> Attr {
474    // attribute = ident ( "=" quoted )?
475    let mut name = String::new();
476    let mut value: Option<String> = None;
477
478    for part in attr.into_inner() {
479        match part.as_rule() {
480            Rule::ident => name = part.as_str().to_string(),
481            Rule::quoted => value = Some(strip_quotes(part.as_str())),
482            _ => {}
483        }
484    }
485    Attr { name, value }
486}
487
488fn strip_quotes(s: &str) -> String {
489    let b = s.as_bytes();
490    if b.len() >= 2
491        && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
492    {
493        s[1..s.len() - 1].to_string()
494    } else {
495        s.to_string()
496    }
497}
498
499fn has_bool_attr(attrs: &[Attr], key: &str) -> bool {
500    attrs.iter().any(|a| a.name == key)
501}
502
503/// Validate a parsed SFC for structural issues that go beyond grammar-level
504/// parsing errors. Returns a list of error messages (empty if valid).
505///
506/// Checks:
507/// - A `<template>` block without any `<script>` or `<script setup>` block
508///   is likely incomplete and cannot produce a functional component.
509pub fn validate_sfc(sfc: &Sfc) -> Vec<String> {
510    let mut errors: Vec<String> = Vec::new();
511
512    // Template without any script block: the component will have no state
513    // or logic, which means render_with_state and event handlers cannot work.
514    if sfc.template.is_some() && sfc.script_setup.is_none() && sfc.script.is_none() {
515        errors.push(
516            "SFC has a <template> block but no <script> or <script setup> block — \
517             the component will lack state and event handling"
518                .to_string(),
519        );
520    }
521
522    errors
523}