use vize_l0::{Allocator, Span, Vec};
use super::{ExprRef, JsExpr, OpaqueExpr};
#[derive(Debug, Clone, Copy)]
pub struct VueFilterApp<'a> {
pub name: &'a str,
pub args: Option<&'a str>,
pub raw: &'a str,
pub span: Span,
}
#[derive(Debug)]
pub struct VueFilterExpr<'a> {
pub source: &'a str,
pub span: Span,
pub base: ExprRef<'a>,
pub filters: Vec<'a, VueFilterApp<'a>>,
}
impl<'a> VueFilterExpr<'a> {
#[must_use]
pub fn parse_in(allocator: &'a Allocator, source: &'a str, span: Span) -> Option<&'a Self> {
let split = split_filters(source)?;
let mut filters = Vec::new_in(&allocator);
for segment in split.filters {
filters.push(parse_app(segment, span)?);
}
if filters.is_empty() {
return None;
}
let base_off = u32::try_from(split.base_offset).unwrap_or(u32::MAX);
let base_start = span.start.saturating_add(base_off);
let base_len = u32::try_from(split.base.len()).unwrap_or(u32::MAX);
let base_span = Span::new(base_start, base_start.saturating_add(base_len));
let base = match JsExpr::parse_in(allocator, split.base, base_span) {
Ok(js) => ExprRef::Js(js),
Err(reason) => ExprRef::Opaque(allocator.alloc(OpaqueExpr {
reason,
source: split.base,
span: base_span,
})),
};
Some(allocator.alloc(Self {
source,
span,
base,
filters,
}))
}
}
struct Segment<'a> {
text: &'a str,
offset: usize,
}
struct Split<'a> {
base: &'a str,
base_offset: usize,
filters: alloc::vec::Vec<Segment<'a>>,
}
fn split_filters(exp: &str) -> Option<Split<'_>> {
let bytes = exp.as_bytes();
let mut in_single = false;
let mut in_double = false;
let mut in_template = false;
let mut in_regex = false;
let mut curly: u32 = 0;
let mut square: u32 = 0;
let mut paren: u32 = 0;
let mut last_filter_index = 0usize;
let mut base: Option<Segment<'_>> = None;
let mut filters = alloc::vec::Vec::new();
let mut prev: u8 = 0;
let mut i = 0usize;
while let Some(&c) = bytes.get(i) {
if in_single {
if c == b'\'' && prev != b'\\' {
in_single = false;
}
} else if in_double {
if c == b'"' && prev != b'\\' {
in_double = false;
}
} else if in_template {
if c == b'`' && prev != b'\\' {
in_template = false;
}
} else if in_regex {
if c == b'/' && prev != b'\\' {
in_regex = false;
}
} else if c == b'|'
&& bytes.get(i + 1).copied() != Some(b'|')
&& i.checked_sub(1).and_then(|p| bytes.get(p)) != Some(&b'|')
&& curly == 0
&& square == 0
&& paren == 0
{
if base.is_none() {
last_filter_index = i + 1;
base = Some(trimmed_segment(exp, 0, i));
} else {
filters.push(trimmed_segment(exp, last_filter_index, i));
last_filter_index = i + 1;
}
} else {
match c {
b'"' => in_double = true,
b'\'' => in_single = true,
b'`' => in_template = true,
b'(' => paren += 1,
b')' => paren = paren.saturating_sub(1),
b'[' => square += 1,
b']' => square = square.saturating_sub(1),
b'{' => curly += 1,
b'}' => curly = curly.saturating_sub(1),
_ => {}
}
if c == b'/' {
let head = exp.get(..i).unwrap_or_default();
let p = head.chars().rev().find(|&pc| pc != ' ');
if p.is_none_or(|pc| !is_valid_division_char(pc)) {
in_regex = true;
}
}
}
prev = c;
i += 1;
}
let base = base?;
if last_filter_index != 0 {
filters.push(trimmed_segment(exp, last_filter_index, exp.len()));
}
if filters.is_empty() {
return None;
}
Some(Split {
base: base.text,
base_offset: base.offset,
filters,
})
}
fn trimmed_segment(exp: &str, start: usize, end: usize) -> Segment<'_> {
let raw = exp.get(start..end).unwrap_or_default();
let text = raw.trim();
let offset = start + raw.len() - raw.trim_start().len();
Segment { text, offset }
}
fn parse_app(segment: Segment<'_>, parent: Span) -> Option<VueFilterApp<'_>> {
let raw = segment.text;
if raw.is_empty() {
return None;
}
let (name, args) = match raw.split_once('(') {
None => (raw, None),
Some((name, rest)) => (name.trim(), Some(rest.strip_suffix(')').unwrap_or(rest))),
};
if !is_filter_name(name) {
return None;
}
let start = parent
.start
.saturating_add(u32::try_from(segment.offset).ok()?);
let end = start.saturating_add(u32::try_from(raw.len()).ok()?);
Some(VueFilterApp {
name,
args,
raw,
span: Span::new(start, end),
})
}
fn is_filter_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_alphabetic() && first != '_' && first != '$' {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$' || c == '-')
}
fn is_valid_division_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || matches!(c, ')' | '.' | '+' | '-' | '$' | ']')
}
const _: () = assert!(!core::mem::needs_drop::<VueFilterExpr<'static>>());
const _: () = assert!(!core::mem::needs_drop::<VueFilterApp<'static>>());
#[cfg(target_pointer_width = "64")]
const _: () = {
assert!(core::mem::size_of::<VueFilterApp<'_>>() == 56);
assert!(core::mem::size_of::<VueFilterExpr<'_>>() == 64);
};
#[cfg(test)]
mod tests {
use super::{VueFilterExpr, split_filters};
use vize_l0::{Allocator, Span};
#[test]
fn no_pipe_is_none() {
assert!(split_filters("message").is_none());
assert!(split_filters("a + b").is_none());
assert!(split_filters("a || b").is_none());
}
#[test]
fn nested_and_quoted_pipes_are_not_filters() {
assert!(split_filters("'x | y'").is_none());
assert!(split_filters("`x | y`").is_none());
assert!(split_filters("[a | b]").is_none());
assert!(split_filters("f(a | b)").is_none());
assert!(split_filters("{ a: b | c }").is_none());
}
#[test]
fn a_chain_splits_base_then_filters() {
let split = split_filters("msg | capitalize | f(b)").expect("chain");
assert_eq!(split.base, "msg");
assert_eq!(split.filters.len(), 2);
assert_eq!(split.filters[0].text, "capitalize");
assert_eq!(split.filters[1].text, "f(b)");
}
#[test]
fn parse_in_admits_the_base_and_keeps_call_args() {
let allocator = Allocator::default();
let expr = VueFilterExpr::parse_in(&allocator, "raw | formatId(x)", Span::new(0, 17))
.expect("filter");
assert_eq!(expr.source, "raw | formatId(x)");
assert_eq!(expr.base.source(), "raw");
assert_eq!(expr.filters.len(), 1);
assert_eq!(expr.filters[0].name, "formatId");
assert_eq!(expr.filters[0].args, Some("x"));
}
#[test]
fn a_malformed_filter_name_refuses_the_chain() {
let allocator = Allocator::default();
assert!(VueFilterExpr::parse_in(&allocator, "a | ", Span::new(0, 4)).is_none());
}
#[test]
fn unicode_before_slash_does_not_slice_mid_char() {
let _ = split_filters("λ / re | f");
let split = split_filters("café | cap").expect("unicode base");
assert_eq!(split.base, "café");
assert_eq!(split.filters[0].text, "cap");
}
}