use crate::constants::{MAX_COMMENT_LENGTH, MAX_CSS_FILE_SIZE};
use crate::style::{Declaration, ErrorCode, ParseError, Rule, StyleSheet};
fn make_error(css: &str, pos: usize, message: &str, code: ErrorCode) -> ParseError {
ParseError::at_offset(message, css, pos).with_code(code)
}
fn missing_brace_error(css: &str, pos: usize, expected: char) -> ParseError {
make_error(
css,
pos,
&format!("expected '{}' but found end of input", expected),
ErrorCode::MissingBrace,
)
}
const MAX_CSS_SIZE: usize = MAX_CSS_FILE_SIZE as usize;
const MAX_RULES: usize = 10_000;
const MAX_DECLARATIONS: usize = 10_000; const MAX_KEYFRAMES: usize = 100;
const MAX_KEYFRAME_BLOCKS: usize = 50;
pub fn parse(css: &str) -> Result<StyleSheet, ParseError> {
if css.len() > MAX_CSS_SIZE {
return Err(make_error(
css,
css.len().min(css.len()),
&format!(
"CSS input too large: {} bytes (max: {} bytes). Consider splitting into multiple files.",
css.len(),
MAX_CSS_SIZE
),
ErrorCode::InvalidValue,
));
}
let mut sheet = StyleSheet::new();
let bytes = css.as_bytes();
let mut pos = 0;
let mut total_declarations = 0;
while pos < bytes.len() {
if sheet.rules.len() >= MAX_RULES {
return Err(make_error(
css,
pos,
&format!(
"Too many CSS rules: {} (max: {}). Consider simplifying your styles.",
sheet.rules.len(),
MAX_RULES
),
ErrorCode::InvalidValue,
));
}
pos = skip_whitespace_bytes(bytes, pos);
if pos >= bytes.len() {
break;
}
if bytes[pos..].starts_with(b":root") {
pos = parse_root_variables_str(css, pos, &mut sheet)?;
continue;
}
if bytes[pos..].starts_with(b"@keyframes") {
if sheet.keyframes.len() >= MAX_KEYFRAMES {
return Err(make_error(
css,
pos,
&format!(
"Too many @keyframes definitions: {} (max: {})",
sheet.keyframes.len(),
MAX_KEYFRAMES
),
ErrorCode::InvalidValue,
));
}
pos = parse_keyframes_block(css, pos, &mut sheet)?;
continue;
}
let (selector, new_pos) = parse_selector_str(css, pos)?;
pos = new_pos;
pos = skip_whitespace_bytes(bytes, pos);
if pos >= bytes.len() || bytes[pos] != b'{' {
return Err(make_error(
css,
pos,
&format!(
"expected '{{' after selector '{}', found '{}'",
selector,
if pos < bytes.len() {
bytes[pos] as char
} else {
' '
}
),
ErrorCode::MissingBrace,
));
}
pos += 1;
let (declarations, new_pos) = parse_declarations_str(css, pos)?;
pos = new_pos;
total_declarations += declarations.len();
if total_declarations > MAX_DECLARATIONS {
return Err(make_error(
css,
pos,
&format!(
"Too many CSS declarations: {} (max: {}). Consider simplifying your styles.",
total_declarations, MAX_DECLARATIONS
),
ErrorCode::InvalidValue,
));
}
if pos >= bytes.len() || bytes[pos] != b'}' {
return Err(missing_brace_error(css, pos, '}'));
}
pos += 1;
sheet.rules.push(Rule {
selector,
declarations,
});
}
Ok(sheet)
}
#[inline]
fn skip_whitespace_bytes(bytes: &[u8], mut pos: usize) -> usize {
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
pos
}
fn skip_whitespace_and_comments_bytes(bytes: &[u8], mut pos: usize) -> usize {
loop {
pos = skip_whitespace_bytes(bytes, pos);
if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
pos += 2;
let comment_start = pos;
while pos + 1 < bytes.len() {
if pos - comment_start > MAX_COMMENT_LENGTH {
return bytes.len(); }
if bytes[pos] == b'*' && bytes[pos + 1] == b'/' {
pos += 2; break;
}
pos += 1;
}
if pos >= bytes.len() || pos + 1 >= bytes.len() {
#[cfg(debug_assertions)]
eprintln!("[revue css] warning: unterminated comment in CSS");
return bytes.len();
}
} else {
break;
}
}
pos
}
fn parse_root_variables_str(
css: &str,
mut pos: usize,
sheet: &mut StyleSheet,
) -> Result<usize, ParseError> {
let bytes = css.as_bytes();
pos += 5;
pos = skip_whitespace_bytes(bytes, pos);
if pos >= bytes.len() || bytes[pos] != b'{' {
return Err(make_error(
css,
pos,
"expected '{' after :root",
ErrorCode::MissingBrace,
));
}
pos += 1;
loop {
pos = skip_whitespace_and_comments_bytes(bytes, pos);
if pos >= bytes.len() {
return Err(missing_brace_error(css, pos, '}'));
}
if bytes[pos] == b'}' {
pos += 1;
break;
}
if !bytes[pos..].starts_with(b"--") {
return Err(make_error(
css,
pos,
"CSS variables must start with '--' (e.g., --primary-color)",
ErrorCode::InvalidSyntax,
)
.suggest("use '--variable-name: value;' format"));
}
let start = pos;
while pos < bytes.len() && bytes[pos] != b':' && !bytes[pos].is_ascii_whitespace() {
pos += 1;
}
let name = css[start..pos].to_string();
pos = skip_whitespace_bytes(bytes, pos);
if pos >= bytes.len() || bytes[pos] != b':' {
return Err(make_error(
css,
pos,
"expected ':' after variable name",
ErrorCode::InvalidSyntax,
)
.suggest("format: --variable-name: value;"));
}
pos += 1;
pos = skip_whitespace_bytes(bytes, pos);
let start = pos;
while pos < bytes.len() && bytes[pos] != b';' && bytes[pos] != b'}' {
pos += 1;
}
let value = css[start..pos].trim().to_string();
sheet.variables.insert(name, value);
if pos < bytes.len() && bytes[pos] == b';' {
pos += 1;
}
}
Ok(pos)
}
fn parse_selector_str(css: &str, mut pos: usize) -> Result<(String, usize), ParseError> {
let bytes = css.as_bytes();
let start = pos;
while pos < bytes.len() && bytes[pos] != b'{' {
pos += 1;
}
Ok((css[start..pos].trim().to_string(), pos))
}
fn parse_declarations_str(
css: &str,
mut pos: usize,
) -> Result<(Vec<Declaration>, usize), ParseError> {
let bytes = css.as_bytes();
let mut declarations = Vec::new();
loop {
pos = skip_whitespace_and_comments_bytes(bytes, pos);
if pos >= bytes.len() || bytes[pos] == b'}' {
break;
}
let start = pos;
while pos < bytes.len() && bytes[pos] != b':' && bytes[pos] != b'}' {
pos += 1;
}
let property = css[start..pos].trim().to_string();
if pos >= bytes.len() || bytes[pos] == b'}' {
break;
}
pos += 1;
pos = skip_whitespace_bytes(bytes, pos);
let start = pos;
let mut paren_depth: i32 = 0;
while pos < bytes.len() {
match bytes[pos] {
b'(' => paren_depth += 1,
b')' => paren_depth = paren_depth.saturating_sub(1),
b';' | b'}' if paren_depth == 0 => break,
_ => {}
}
pos += 1;
}
let value = css[start..pos].trim().to_string();
if !property.is_empty() {
declarations.push(Declaration { property, value });
}
if pos < bytes.len() && bytes[pos] == b';' {
pos += 1;
}
}
Ok((declarations, pos))
}
fn parse_keyframes_block(
css: &str,
mut pos: usize,
sheet: &mut StyleSheet,
) -> Result<usize, ParseError> {
let bytes = css.as_bytes();
pos += 10;
pos = skip_whitespace_bytes(bytes, pos);
let name_start = pos;
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'{' {
pos += 1;
}
let name = css[name_start..pos].trim().to_string();
if name.is_empty() {
return Err(make_error(
css,
name_start,
"expected name after @keyframes",
ErrorCode::InvalidSyntax,
));
}
pos = skip_whitespace_bytes(bytes, pos);
if pos >= bytes.len() || bytes[pos] != b'{' {
return Err(make_error(
css,
pos,
"expected '{' after @keyframes name",
ErrorCode::MissingBrace,
));
}
pos += 1;
let mut keyframe_blocks = Vec::new();
loop {
pos = skip_whitespace_and_comments_bytes(bytes, pos);
if pos >= bytes.len() {
return Err(missing_brace_error(css, pos, '}'));
}
if bytes[pos] == b'}' {
pos += 1;
break;
}
if keyframe_blocks.len() >= MAX_KEYFRAME_BLOCKS {
return Err(make_error(
css,
pos,
&format!(
"Too many keyframe blocks in @keyframes '{}': {} (max: {})",
name,
keyframe_blocks.len(),
MAX_KEYFRAME_BLOCKS
),
ErrorCode::InvalidValue,
));
}
let (percent, new_pos) = parse_keyframe_selector(css, pos)?;
pos = new_pos;
pos = skip_whitespace_bytes(bytes, pos);
if pos >= bytes.len() || bytes[pos] != b'{' {
return Err(make_error(
css,
pos,
"expected '{' after keyframe selector",
ErrorCode::MissingBrace,
));
}
pos += 1;
let (declarations, new_pos) = parse_declarations_str(css, pos)?;
pos = new_pos;
if pos >= bytes.len() || bytes[pos] != b'}' {
return Err(missing_brace_error(css, pos, '}'));
}
pos += 1;
keyframe_blocks.push(crate::style::KeyframeBlock {
percent,
declarations,
});
}
sheet.keyframes.insert(
name.clone(),
crate::style::KeyframesDefinition {
name,
keyframes: keyframe_blocks,
},
);
Ok(pos)
}
fn parse_keyframe_selector(css: &str, mut pos: usize) -> Result<(u8, usize), ParseError> {
let bytes = css.as_bytes();
let start = pos;
while pos < bytes.len()
&& !bytes[pos].is_ascii_whitespace()
&& bytes[pos] != b'{'
&& bytes[pos] != b','
{
pos += 1;
}
let selector = css[start..pos].trim();
let percent = match selector {
"from" => 0,
"to" => 100,
s if s.ends_with('%') => {
let num_str = &s[..s.len() - 1];
num_str
.parse::<u8>()
.map_err(|_| {
make_error(
css,
start,
&format!("invalid keyframe percentage: '{}'", s),
ErrorCode::InvalidValue,
)
})?
.min(100)
}
_ => {
return Err(make_error(
css,
start,
&format!(
"invalid keyframe selector '{}': expected 'from', 'to', or 'N%'",
selector
),
ErrorCode::InvalidSyntax,
));
}
};
Ok((percent, pos))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_normal_css() {
let css = r#"
.button {
background: blue;
color: white;
}
"#;
assert!(parse(css).is_ok());
}
#[test]
fn test_css_size_limit() {
let mut large_css = String::new();
large_css.push_str(".test { content: ");
for _ in 0..1_200_000 {
large_css.push('x');
}
large_css.push_str("; }");
let result = parse(&large_css);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("too large"));
}
#[test]
fn test_css_rules_limit() {
let mut css = String::new();
for i in 0..10_001 {
css.push_str(&format!(".class{} {{ color: red; }}", i));
}
let result = parse(&css);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("Too many CSS rules"));
}
#[test]
fn test_css_declarations_limit() {
let mut css = String::new();
for rule in 0..2 {
css.push_str(&format!(".rule{} {{ ", rule));
for i in 0..5_001 {
css.push_str(&format!("prop{}: val{}; ", i, i));
}
css.push_str("} ");
}
let result = parse(&css);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("CSS declarations") || err.message.contains("declarations"));
}
#[test]
fn test_css_within_limits() {
let mut css = String::new();
for i in 0..100 {
css.push_str(&format!(".class{} {{ ", i));
for j in 0..10 {
css.push_str(&format!("prop{}: val{}; ", j, j));
}
css.push_str("}");
}
assert!(parse(&css).is_ok());
}
#[test]
fn test_css_normal_comments() {
let css = r#"
/* This is a normal comment */
.box { width: 100; }
/* Another comment */
.text { color: red; }
"#;
assert!(parse(&css).is_ok());
}
#[test]
fn test_css_multiline_comment() {
let css = r#"
/* This is a
multi-line
comment */
.box { width: 100; }
"#;
assert!(parse(&css).is_ok());
}
#[test]
fn test_css_nested_comments_wont_hang() {
let css = "/* outer /* inner */ comment */ .box { width: 100; }";
let _ = parse(&css);
}
#[test]
fn test_css_unterminated_comment_is_safe() {
let css = "/* This comment is never closed .box { width: 100; }";
let result = parse(&css);
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_css_comment_after_property() {
let css = ".box { width: 100; /* comment after */ }";
assert!(parse(&css).is_ok());
}
#[test]
fn test_css_empty_comment() {
let css = "/**/ .box { width: 100; }";
assert!(parse(&css).is_ok());
}
#[test]
fn test_keyframes_from_to() {
let css = r#"
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
"#;
let sheet = parse(css).unwrap();
assert!(sheet.keyframes.contains_key("fadeIn"));
let def = &sheet.keyframes["fadeIn"];
assert_eq!(def.keyframes.len(), 2);
assert_eq!(def.keyframes[0].percent, 0);
assert_eq!(def.keyframes[1].percent, 100);
}
#[test]
fn test_keyframes_percentages() {
let css = r#"
@keyframes slide {
0% { x: 0; }
50% { x: 50; }
100% { x: 100; }
}
"#;
let sheet = parse(css).unwrap();
let def = &sheet.keyframes["slide"];
assert_eq!(def.keyframes.len(), 3);
assert_eq!(def.keyframes[0].percent, 0);
assert_eq!(def.keyframes[1].percent, 50);
assert_eq!(def.keyframes[2].percent, 100);
}
#[test]
fn test_keyframes_empty_body() {
let css = "@keyframes empty {}";
let sheet = parse(css).unwrap();
assert_eq!(sheet.keyframes["empty"].keyframes.len(), 0);
}
#[test]
fn test_keyframes_missing_name_error() {
let css = "@keyframes { from { opacity: 0; } }";
assert!(parse(css).is_err());
}
#[test]
fn test_keyframes_with_regular_rules() {
let css = r#"
.btn { color: red; }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.text { color: blue; }
"#;
let sheet = parse(css).unwrap();
assert_eq!(sheet.rules.len(), 2);
assert!(sheet.keyframes.contains_key("fadeIn"));
}
#[test]
fn test_keyframes_invalid_selector() {
let css = r#"
@keyframes test {
invalid { opacity: 0; }
}
"#;
assert!(parse(css).is_err());
}
}