use pest::Parser;
use pest::error::ErrorVariant;
use pest::iterators::Pair;
#[derive(pest_derive::Parser)]
#[grammar = "grammar.pest"]
struct SfcParser;
#[derive(Debug, Clone, PartialEq)]
pub struct Attr {
pub name: String,
pub value: Option<String>, }
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TemplateBlock {
pub attrs: Vec<Attr>,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ScriptBlock {
pub attrs: Vec<Attr>,
pub content: String,
pub setup: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyleBlock {
pub attrs: Vec<Attr>,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Sfc {
pub template: Option<TemplateBlock>,
pub script_setup: Option<ScriptBlock>,
pub script: Option<ScriptBlock>,
pub style: Option<StyleBlock>,
pub warnings: Vec<String>,
}
fn format_pest_error(err: pest::error::Error<Rule>, source: &str) -> String {
let (line, column) = match err.line_col {
pest::error::LineColLocation::Pos((l, c)) => (l, c),
pest::error::LineColLocation::Span((l, c), _) => (l, c),
};
let mut expected: Vec<String> = Vec::new();
let mut unexpected: Vec<String> = Vec::new();
let description = match &err.variant {
ErrorVariant::ParsingError {
positives,
negatives,
} => {
expected = positives.iter().map(rule_to_block_name).collect::<Vec<_>>();
unexpected = negatives.iter().map(rule_to_block_name).collect::<Vec<_>>();
if expected.is_empty() {
"unexpected token found while parsing the SFC".to_string()
} else {
let unexpected_str = if unexpected.is_empty() {
"an unexpected token".to_string()
} else {
unexpected.join(", ")
};
format!(
"expected {} but found {}",
expected.join(", "),
unexpected_str
)
}
}
ErrorVariant::CustomError { message } => message.clone(),
};
let block_context = infer_block_context(source, line);
let message = format!("{description} while parsing the {block_context} block");
let suggestion = suggest_pest_error(&expected, &unexpected, &block_context);
crate::diagnostic::render_parse_error(source, line, column, 1, &message, suggestion.as_deref())
}
fn suggest_pest_error(expected: &[String], unexpected: &[String], block: &str) -> Option<String> {
let end_of_input = expected
.iter()
.any(|e| e.contains("end of input") || e.contains("SFC file"));
let unexp_join = unexpected.join(" ");
if end_of_input {
return Some(format!(
"the {block} block may be missing its closing tag (e.g. </{block}>), or a block was never opened"
));
}
if block != "top-level" {
return Some(format!(
"check the {block} tags and attribute quotes near this line; \
every value after '=' should be wrapped in \"double\" or 'single' quotes"
));
}
if unexp_join.contains("end of input") {
return Some(
"the file ended before a block was closed — add the missing </template>, </script>, or </style>".to_string(),
);
}
if expected.iter().any(|e| e.contains("<template")) {
return Some(
"after a <template> block you need a <script setup> block to define component state and logic"
.to_string(),
);
}
None
}
fn rule_to_block_name(rule: &Rule) -> String {
match rule {
Rule::template | Rule::template_open | Rule::template_body | Rule::nested_template => {
"<template>".to_string()
}
Rule::slot_attr => "slot binding (v-slot:name or #name)".to_string(),
Rule::slot_name => "slot name".to_string(),
Rule::script | Rule::script_open | Rule::script_body => "<script>".to_string(),
Rule::style | Rule::style_open | Rule::style_body => "<style>".to_string(),
Rule::attribute => "attribute".to_string(),
Rule::ident => "identifier".to_string(),
Rule::quoted | Rule::dq | Rule::sq => "quoted value".to_string(),
Rule::block => "SFC block (<template>, <script>, or <style>)".to_string(),
Rule::file => "SFC file".to_string(),
Rule::WS => "whitespace".to_string(),
Rule::EOI => "end of input".to_string(),
}
}
fn infer_block_context(source: &str, error_line: usize) -> String {
let lines: Vec<&str> = source.lines().collect();
let mut last_block = "top-level";
for (idx, line) in lines.iter().enumerate() {
let ln = idx + 1;
if ln > error_line {
break;
}
let trimmed = line.trim();
if trimmed.starts_with("<template") {
last_block = "template";
} else if trimmed.starts_with("<script") {
last_block = "script";
} else if trimmed.starts_with("<style") {
last_block = "style";
} else if trimmed.starts_with("</template")
|| trimmed.starts_with("</script")
|| trimmed.starts_with("</style")
{
last_block = "top-level";
}
}
last_block.to_string()
}
pub const MAX_NESTED_TEMPLATE_DEPTH: usize = 256;
fn nested_template_openers(source: &str) -> (usize, usize) {
const OPEN: &str = "<template";
const WS: [char; 4] = [' ', '\t', '\r', '\n'];
let mut count = 0usize;
let mut offending_offset = 0usize;
for (i, _) in source.char_indices() {
if !source[i..].starts_with(OPEN) {
continue;
}
let opens_a_block = source[i + OPEN.len()..]
.chars()
.next()
.is_some_and(|after| after == '>' || WS.contains(&after));
if !opens_a_block {
continue;
}
if count == MAX_NESTED_TEMPLATE_DEPTH {
offending_offset = i;
}
count += 1;
}
(count, offending_offset)
}
fn check_nested_template_depth(source: &str) -> Result<(), String> {
let (openers, offending_offset) = nested_template_openers(source);
if openers <= MAX_NESTED_TEMPLATE_DEPTH {
return Ok(());
}
let (line, col) = crate::diagnostic::line_col_at(source, offending_offset);
Err(crate::diagnostic::render_parse_error(
source,
line,
col,
"<template".len(),
&format!(
"{openers} nested `<template` blocks exceed the limit of \
{MAX_NESTED_TEMPLATE_DEPTH}"
),
Some(
"nest fewer than 256 `<template` blocks deep, or flatten the markup — \
a `<template v-slot:name>` block is the only thing that nests, so this \
means a slot-fragment tree far deeper than any real template",
),
))
}
pub fn parse_sfc(source: &str) -> Result<Sfc, String> {
let mut sfc = Sfc::default();
check_nested_template_depth(source)?;
let mut pairs =
SfcParser::parse(Rule::file, source).map_err(|e| format_pest_error(e, source))?;
let file = pairs
.next()
.ok_or_else(|| "SFC parse error: input is empty".to_string())?;
debug_assert!(file.as_rule() == Rule::file);
for node in file.into_inner() {
match node.as_rule() {
Rule::block => {
for inner in node.into_inner() {
consume_top_level(inner, &mut sfc);
}
}
Rule::template | Rule::script | Rule::style => {
consume_top_level(node, &mut sfc);
}
_ => {}
}
}
sfc.warnings = template_warnings(&sfc);
Ok(sfc)
}
fn template_warnings(sfc: &Sfc) -> Vec<String> {
let Some(template) = &sfc.template else {
return Vec::new();
};
let mut known: Vec<String> = Vec::new();
if let Some(script_setup) = &sfc.script_setup {
let mut resolver =
crate::component_resolver::ComponentResolver::new(std::path::PathBuf::from("."));
resolver.parse_imports(&script_setup.content);
known.extend(resolver.component_names());
}
let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
match crate::template_parse::parse_template(&template.content, &known_refs) {
Ok(diag) => diag.warnings,
Err(_) => Vec::new(),
}
}
fn consume_top_level(node: Pair<Rule>, sfc: &mut Sfc) {
match node.as_rule() {
Rule::template => {
let (attrs, content) = parse_template(node);
sfc.template = Some(TemplateBlock { attrs, content });
}
Rule::script => {
let (attrs, content) = parse_script(node);
let setup = has_bool_attr(&attrs, "setup");
let sb = ScriptBlock {
attrs,
content,
setup,
};
if setup {
sfc.script_setup = Some(sb);
} else {
sfc.script = Some(sb);
}
}
Rule::style => {
let (attrs, content) = parse_style(node);
sfc.style = Some(StyleBlock { attrs, content });
}
_ => {}
}
}
fn parse_template(tpl: Pair<Rule>) -> (Vec<Attr>, String) {
let mut attrs = Vec::new();
let mut content = String::new();
for p in tpl.into_inner() {
match p.as_rule() {
Rule::template_open => {
for a in p.into_inner() {
if a.as_rule() == Rule::attribute {
attrs.push(parse_attr(a));
}
}
}
Rule::template_body => content = p.as_str().to_string(),
_ => {}
}
}
(attrs, content)
}
fn parse_script(scr: Pair<Rule>) -> (Vec<Attr>, String) {
let mut attrs = Vec::new();
let mut content = String::new();
for p in scr.into_inner() {
match p.as_rule() {
Rule::script_open => {
for a in p.into_inner() {
if a.as_rule() == Rule::attribute {
attrs.push(parse_attr(a));
}
}
}
Rule::script_body => content = p.as_str().to_string(),
_ => {}
}
}
(attrs, content)
}
fn parse_style(sty: Pair<Rule>) -> (Vec<Attr>, String) {
let mut attrs = Vec::new();
let mut content = String::new();
for p in sty.into_inner() {
match p.as_rule() {
Rule::style_open => {
for a in p.into_inner() {
if a.as_rule() == Rule::attribute {
attrs.push(parse_attr(a));
}
}
}
Rule::style_body => content = p.as_str().to_string(),
_ => {}
}
}
(attrs, content)
}
fn parse_attr(attr: Pair<Rule>) -> Attr {
let mut name = String::new();
let mut value: Option<String> = None;
for part in attr.into_inner() {
match part.as_rule() {
Rule::ident => name = part.as_str().to_string(),
Rule::quoted => value = Some(strip_quotes(part.as_str())),
_ => {}
}
}
Attr { name, value }
}
fn strip_quotes(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 2
&& ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
fn has_bool_attr(attrs: &[Attr], key: &str) -> bool {
attrs.iter().any(|a| a.name == key)
}
pub fn validate_sfc(sfc: &Sfc) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
if sfc.template.is_some() && sfc.script_setup.is_none() && sfc.script.is_none() {
errors.push(
"SFC has a <template> block but no <script> or <script setup> block — \
the component will lack state and event handling"
.to_string(),
);
}
errors
}