diff --git a/README.md b/README.md index 3d3ae8a..c2215fe 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,15 @@ for this version will be provided when the library becomes more stable. The large features remaining are ``` -builtin functions min, max -indented syntax (27 tests) +indented syntax css imports -@use, @forward, and the module system (~1000 tests) -@keyframes (~30 tests) +@use, @forward, and the module system +@keyframes ``` +This is in addition to dozens of smaller features, edge cases, and miscompilations. +Features currently blocking Bootstrap are tracked [here](https://github.com/connorskees/grass/issues/4). + ## Features ### commandline diff --git a/src/args.rs b/src/args.rs index 498f4f7..ea1626f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -145,6 +145,17 @@ impl CallArgs { self.0.is_empty() } + pub fn min_args(&self, min: usize) -> SassResult<()> { + let len = self.len(); + if len < min { + if min == 1 { + return Err(("At least one argument must be passed.", self.span()).into()); + } + todo!("min args greater than one") + } + Ok(()) + } + pub fn max_args(&self, max: usize) -> SassResult<()> { let len = self.len(); if len > max { diff --git a/src/builtin/math.rs b/src/builtin/math.rs index 1ff1e75..5fc13f8 100644 --- a/src/builtin/math.rs +++ b/src/builtin/math.rs @@ -7,6 +7,7 @@ use rand::Rng; use crate::{ args::CallArgs, + common::Op, error::SassResult, parse::Parser, unit::Unit, @@ -189,12 +190,78 @@ fn random(mut args: CallArgs, parser: &mut Parser<'_>) -> SassResult { )) } +fn min(args: CallArgs, parser: &mut Parser<'_>) -> SassResult { + args.min_args(1)?; + let span = args.span(); + let mut nums = parser + .variadic_args(args)? + .into_iter() + .map(|val| match val.node { + Value::Dimension(number, unit) => Ok((number, unit)), + v => Err((format!("{} is not a number.", v.inspect(span)?), span).into()), + }) + .collect::>>()? + .into_iter(); + + // we know that there *must* be at least one item + let mut min = nums.next().unwrap(); + + for num in nums { + if Value::Dimension(num.0.clone(), num.1.clone()) + .cmp( + Value::Dimension(min.0.clone(), min.1.clone()), + Op::LessThan, + span, + )? + .node + .is_true(span)? + { + min = num; + } + } + Ok(Value::Dimension(min.0, min.1)) +} + +fn max(args: CallArgs, parser: &mut Parser<'_>) -> SassResult { + args.min_args(1)?; + let span = args.span(); + let mut nums = parser + .variadic_args(args)? + .into_iter() + .map(|val| match val.node { + Value::Dimension(number, unit) => Ok((number, unit)), + v => Err((format!("{} is not a number.", v.inspect(span)?), span).into()), + }) + .collect::>>()? + .into_iter(); + + // we know that there *must* be at least one item + let mut max = nums.next().unwrap(); + + for num in nums { + if Value::Dimension(num.0.clone(), num.1.clone()) + .cmp( + Value::Dimension(max.0.clone(), max.1.clone()), + Op::GreaterThan, + span, + )? + .node + .is_true(span)? + { + max = num; + } + } + Ok(Value::Dimension(max.0, max.1)) +} + pub(crate) fn declare(f: &mut GlobalFunctionMap) { f.insert("percentage", Builtin::new(percentage)); f.insert("round", Builtin::new(round)); f.insert("ceil", Builtin::new(ceil)); f.insert("floor", Builtin::new(floor)); f.insert("abs", Builtin::new(abs)); + f.insert("min", Builtin::new(min)); + f.insert("max", Builtin::new(max)); f.insert("comparable", Builtin::new(comparable)); #[cfg(feature = "random")] f.insert("random", Builtin::new(random)); diff --git a/src/parse/value.rs b/src/parse/value.rs index 1d3bbe9..d396f9f 100644 --- a/src/parse/value.rs +++ b/src/parse/value.rs @@ -15,9 +15,9 @@ use crate::{ error::SassResult, unit::Unit, utils::{ - as_hex, devour_whitespace, eat_number, hex_char_for, is_name, + as_hex, devour_whitespace, eat_number, hex_char_for, is_name, peek_ident_no_interpolation, peek_until_closing_curly_brace, peek_whitespace, read_until_char, read_until_closing_paren, - read_until_closing_square_brace, IsWhitespace, + read_until_closing_square_brace, IsWhitespace, }, value::Value, value::{Number, SassMap}, @@ -183,6 +183,35 @@ impl<'a> Parser<'a> { if let Some(Token { kind: '(', .. }) = self.toks.peek() { self.toks.next(); + + if lower == "min" { + match self.try_parse_min_max("min", true)? { + Some((val, len)) => { + self.toks.take(len).for_each(drop); + return Ok( + IntermediateValue::Value(Value::String(val, QuoteKind::None)) + .span(span), + ); + } + None => { + self.toks.reset_cursor(); + } + } + } else if lower == "max" { + match self.try_parse_min_max("max", true)? { + Some((val, len)) => { + self.toks.take(len).for_each(drop); + return Ok( + IntermediateValue::Value(Value::String(val, QuoteKind::None)) + .span(span), + ); + } + None => { + self.toks.reset_cursor(); + } + } + } + let as_ident = Identifier::from(&s); let ident_as_string = as_ident.clone().into_inner(); let func = match self.scopes.last().get_fn( @@ -206,8 +235,6 @@ impl<'a> Parser<'a> { s = lower; self.eat_calc_args(&mut s)?; } - // "min" => {} - // "max" => {} "url" => match self.try_eat_url()? { Some(val) => s = val, None => s.push_str(&self.parse_call_args()?.to_css_string(self)?), @@ -715,6 +742,243 @@ impl<'a> Parser<'a> { Ok(None) } + fn peek_number(&mut self) -> SassResult> { + let mut buf = String::new(); + let mut peek_counter = 0; + + let (num, count) = self.peek_whole_number(); + peek_counter += count; + buf.push_str(&num); + + self.toks.advance_cursor(); + + if let Some(Token { kind: '.', .. }) = self.toks.peek() { + self.toks.advance_cursor(); + let (num, count) = self.peek_whole_number(); + if count == 0 { + return Ok(None); + } + peek_counter += count; + buf.push_str(&num); + } else { + self.toks.move_cursor_back().unwrap(); + } + + let next = match self.toks.peek() { + Some(tok) => tok, + None => return Ok(Some((buf, peek_counter))), + }; + + match next.kind { + 'a'..='z' | 'A'..='Z' | '-' | '_' | '\\' => { + let unit = peek_ident_no_interpolation(self.toks, true, self.span_before)?.node; + + buf.push_str(&unit); + peek_counter += unit.chars().count(); + } + '%' => { + self.toks.advance_cursor(); + peek_counter += 1; + buf.push('%'); + } + _ => {} + } + + Ok(Some((buf, peek_counter))) + } + + fn peek_whole_number(&mut self) -> (String, usize) { + let mut buf = String::new(); + let mut peek_counter = 0; + while let Some(tok) = self.toks.peek() { + if tok.kind.is_ascii_digit() { + buf.push(tok.kind); + peek_counter += 1; + self.toks.advance_cursor(); + } else { + return (buf, peek_counter); + } + } + (buf, peek_counter) + } + + fn try_parse_min_max( + &mut self, + fn_name: &str, + allow_comma: bool, + ) -> SassResult> { + let mut buf = if allow_comma { + format!("{}(", fn_name) + } else { + String::new() + }; + let mut peek_counter = 0; + peek_counter += peek_whitespace(self.toks); + while let Some(tok) = self.toks.peek() { + let kind = tok.kind; + peek_counter += 1; + match kind { + '+' | '-' | '0'..='9' => { + self.toks.advance_cursor(); + if let Some((number, count)) = self.peek_number()? { + buf.push(kind); + buf.push_str(&number); + peek_counter += count; + } else { + return Ok(None); + } + } + '#' => { + self.toks.advance_cursor(); + if let Some(Token { kind: '{', .. }) = self.toks.peek() { + self.toks.advance_cursor(); + peek_counter += 1; + let (interpolation, count) = self.peek_interpolation()?; + peek_counter += count; + match interpolation.node { + Value::String(ref s, ..) => buf.push_str(s), + v => buf.push_str(v.to_css_string(interpolation.span)?.borrow()), + }; + } else { + return Ok(None); + } + } + 'c' | 'C' => { + if let Some((name, additional_peek_count)) = + self.try_parse_min_max_function("calc")? + { + peek_counter += additional_peek_count; + buf.push_str(&name); + } else { + return Ok(None); + } + } + 'e' | 'E' => { + if let Some((name, additional_peek_count)) = + self.try_parse_min_max_function("env")? + { + peek_counter += additional_peek_count; + buf.push_str(&name); + } else { + return Ok(None); + } + } + 'v' | 'V' => { + if let Some((name, additional_peek_count)) = + self.try_parse_min_max_function("var")? + { + peek_counter += additional_peek_count; + buf.push_str(&name); + } else { + return Ok(None); + } + } + '(' => { + self.toks.advance_cursor(); + buf.push('('); + if let Some((val, len)) = self.try_parse_min_max(fn_name, false)? { + buf.push_str(&val); + peek_counter += len; + } else { + return Ok(None); + } + } + 'm' | 'M' => { + self.toks.advance_cursor(); + match self.toks.peek() { + Some(Token { kind: 'i', .. }) | Some(Token { kind: 'I', .. }) => { + self.toks.advance_cursor(); + if !matches!(self.toks.peek(), Some(Token { kind: 'n', .. }) | Some(Token { kind: 'N', .. })) + { + return Ok(None); + } + buf.push_str("min(") + } + Some(Token { kind: 'a', .. }) | Some(Token { kind: 'A', .. }) => { + self.toks.advance_cursor(); + if !matches!(self.toks.peek(), Some(Token { kind: 'x', .. }) | Some(Token { kind: 'X', .. })) + { + return Ok(None); + } + buf.push_str("max(") + } + _ => return Ok(None), + } + + self.toks.advance_cursor(); + + if !matches!(self.toks.peek(), Some(Token { kind: '(', .. })) { + return Ok(None); + } + peek_counter += 1; + + if let Some((val, len)) = self.try_parse_min_max(fn_name, false)? { + buf.push_str(&val); + peek_counter += len; + } else { + return Ok(None); + } + } + _ => return Ok(None), + } + + peek_counter += peek_whitespace(self.toks); + + let next = match self.toks.peek() { + Some(tok) => tok, + None => return Ok(None), + }; + + match next.kind { + ')' => { + peek_counter += 1; + self.toks.advance_cursor(); + buf.push(')'); + return Ok(Some((buf, peek_counter))); + } + '+' | '-' | '*' | '/' => { + buf.push(' '); + buf.push(next.kind); + buf.push(' '); + self.toks.advance_cursor(); + } + ',' => { + if !allow_comma { + return Ok(None); + } + self.toks.advance_cursor(); + buf.push(','); + buf.push(' '); + } + _ => return Ok(None), + } + + peek_counter += peek_whitespace(self.toks); + } + + Ok(Some((buf, peek_counter))) + } + + #[allow(dead_code, unused_mut, unused_variables, unused_assignments)] + fn try_parse_min_max_function( + &mut self, + fn_name: &'static str, + ) -> SassResult> { + let mut ident = peek_ident_no_interpolation(self.toks, false, self.span_before)?.node; + let mut peek_counter = ident.chars().count(); + ident.make_ascii_lowercase(); + if ident != fn_name { + return Ok(None); + } + if !matches!(self.toks.peek(), Some(Token { kind: '(', .. })) { + return Ok(None); + } + self.toks.advance_cursor(); + ident.push('('); + peek_counter += 1; + todo!("special functions inside `min()` or `max()`") + } + fn peek_interpolation(&mut self) -> SassResult<(Spanned, usize)> { let vec = peek_until_closing_curly_brace(self.toks)?; let peek_counter = vec.len(); diff --git a/tests/min-max.rs b/tests/min-max.rs new file mode 100644 index 0000000..3f77fcc --- /dev/null +++ b/tests/min-max.rs @@ -0,0 +1,104 @@ +#![cfg(test)] + +#[macro_use] +mod macros; + +test!( + min_not_evaluated_units_percent, + "a {\n color: min(1%, 2%);\n}\n", + "a {\n color: min(1%, 2%);\n}\n" +); +test!( + min_not_evaluated_units_px, + "a {\n color: min(1px, 2px);\n}\n", + "a {\n color: min(1px, 2px);\n}\n" +); +test!( + min_not_evaluated_no_units, + "a {\n color: min(1, 2);\n}\n", + "a {\n color: min(1, 2);\n}\n" +); +test!( + min_not_evaluated_incompatible_units, + "a {\n color: min(1%, 2vh);\n}\n", + "a {\n color: min(1%, 2vh);\n}\n" +); +test!( + min_not_evaluated_interpolation, + "$a: 1%;\n$b: 2%;\na {\n color: min(#{$a}, #{$b});;\n}\n", + "a {\n color: min(1%, 2%);\n}\n" +); +test!( + min_evaluated_variable_units_percent, + "$a: 1%;\n$b: 2%;\na {\n color: min($a, $b);\n}\n", + "a {\n color: 1%;\n}\n" +); +test!( + min_evaluated_variable_units_px, + "$a: 1px;\n$b: 2px;\na {\n color: min($a, $b);\n}\n", + "a {\n color: 1px;\n}\n" +); +error!( + min_arg_of_incorrect_type, + "$a: 1px;\n$b: 2px;\na {\n color: min($a, $b, foo);\n}\n", "Error: foo is not a number." +); +error!( + min_too_few_args, + "a {\n color: min();\n}\n", "Error: At least one argument must be passed." +); +// note: we explicitly have units in the opposite order of `dart-sass`. +// see https://github.com/sass/dart-sass/issues/766 +error!( + min_incompatible_units, + "$a: 1px;\n$b: 2%;\na {\n color: min($a, $b);\n}\n", "Error: Incompatible units px and %." +); +test!( + max_not_evaluated_units_percent, + "a {\n color: max(1%, 2%);\n}\n", + "a {\n color: max(1%, 2%);\n}\n" +); +test!( + max_not_evaluated_units_px, + "a {\n color: max(1px, 2px);\n}\n", + "a {\n color: max(1px, 2px);\n}\n" +); +test!( + max_not_evaluated_no_units, + "a {\n color: max(1, 2);\n}\n", + "a {\n color: max(1, 2);\n}\n" +); +test!( + max_not_evaluated_incompatible_units, + "a {\n color: max(1%, 2vh);\n}\n", + "a {\n color: max(1%, 2vh);\n}\n" +); +test!( + max_not_evaluated_interpolation, + "$a: 1%;\n$b: 2%;\na {\n color: max(#{$a}, #{$b});;\n}\n", + "a {\n color: max(1%, 2%);\n}\n" +); +test!( + max_evaluated_variable_units_percent, + "$a: 1%;\n$b: 2%;\na {\n color: max($a, $b);\n}\n", + "a {\n color: 2%;\n}\n" +); +test!( + max_evaluated_variable_units_px, + "$a: 1px;\n$b: 2px;\na {\n color: max($a, $b);\n}\n", + "a {\n color: 2px;\n}\n" +); +error!( + max_arg_of_incorrect_type, + "$a: 1px;\n$b: 2px;\na {\n color: max($a, $b, foo);\n}\n", "Error: foo is not a number." +); +error!( + max_too_few_args, + "a {\n color: max();\n}\n", "Error: At least one argument must be passed." +); +// note: we explicitly have units in the opposite order of `dart-sass`. +// see https://github.com/sass/dart-sass/issues/766 +error!( + max_incompatible_units, + "$a: 1px;\n$b: 2%;\na {\n color: max($a, $b);\n}\n", "Error: Incompatible units px and %." +); +// todo: special functions, min(calc(1), $b); \ No newline at end of file