implement special-cased functions min and max

This commit is contained in:
ConnorSkees 2020-06-22 10:11:30 -04:00
parent 1362d747a4
commit 082d58853b
5 changed files with 456 additions and 8 deletions

View File

@ -31,13 +31,15 @@ for this version will be provided when the library becomes more stable.
The large features remaining are The large features remaining are
``` ```
builtin functions min, max indented syntax
indented syntax (27 tests)
css imports css imports
@use, @forward, and the module system (~1000 tests) @use, @forward, and the module system
@keyframes (~30 tests) @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 ## Features
### commandline ### commandline

View File

@ -145,6 +145,17 @@ impl CallArgs {
self.0.is_empty() 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<()> { pub fn max_args(&self, max: usize) -> SassResult<()> {
let len = self.len(); let len = self.len();
if len > max { if len > max {

View File

@ -7,6 +7,7 @@ use rand::Rng;
use crate::{ use crate::{
args::CallArgs, args::CallArgs,
common::Op,
error::SassResult, error::SassResult,
parse::Parser, parse::Parser,
unit::Unit, unit::Unit,
@ -189,12 +190,78 @@ fn random(mut args: CallArgs, parser: &mut Parser<'_>) -> SassResult<Value> {
)) ))
} }
fn min(args: CallArgs, parser: &mut Parser<'_>) -> SassResult<Value> {
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::<SassResult<Vec<(Number, Unit)>>>()?
.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<Value> {
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::<SassResult<Vec<(Number, Unit)>>>()?
.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) { pub(crate) fn declare(f: &mut GlobalFunctionMap) {
f.insert("percentage", Builtin::new(percentage)); f.insert("percentage", Builtin::new(percentage));
f.insert("round", Builtin::new(round)); f.insert("round", Builtin::new(round));
f.insert("ceil", Builtin::new(ceil)); f.insert("ceil", Builtin::new(ceil));
f.insert("floor", Builtin::new(floor)); f.insert("floor", Builtin::new(floor));
f.insert("abs", Builtin::new(abs)); f.insert("abs", Builtin::new(abs));
f.insert("min", Builtin::new(min));
f.insert("max", Builtin::new(max));
f.insert("comparable", Builtin::new(comparable)); f.insert("comparable", Builtin::new(comparable));
#[cfg(feature = "random")] #[cfg(feature = "random")]
f.insert("random", Builtin::new(random)); f.insert("random", Builtin::new(random));

View File

@ -15,9 +15,9 @@ use crate::{
error::SassResult, error::SassResult,
unit::Unit, unit::Unit,
utils::{ 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, 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::Value,
value::{Number, SassMap}, value::{Number, SassMap},
@ -183,6 +183,35 @@ impl<'a> Parser<'a> {
if let Some(Token { kind: '(', .. }) = self.toks.peek() { if let Some(Token { kind: '(', .. }) = self.toks.peek() {
self.toks.next(); 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 as_ident = Identifier::from(&s);
let ident_as_string = as_ident.clone().into_inner(); let ident_as_string = as_ident.clone().into_inner();
let func = match self.scopes.last().get_fn( let func = match self.scopes.last().get_fn(
@ -206,8 +235,6 @@ impl<'a> Parser<'a> {
s = lower; s = lower;
self.eat_calc_args(&mut s)?; self.eat_calc_args(&mut s)?;
} }
// "min" => {}
// "max" => {}
"url" => match self.try_eat_url()? { "url" => match self.try_eat_url()? {
Some(val) => s = val, Some(val) => s = val,
None => s.push_str(&self.parse_call_args()?.to_css_string(self)?), None => s.push_str(&self.parse_call_args()?.to_css_string(self)?),
@ -715,6 +742,243 @@ impl<'a> Parser<'a> {
Ok(None) Ok(None)
} }
fn peek_number(&mut self) -> SassResult<Option<(String, usize)>> {
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<Option<(String, usize)>> {
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<Option<(String, usize)>> {
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<Value>, usize)> { fn peek_interpolation(&mut self) -> SassResult<(Spanned<Value>, usize)> {
let vec = peek_until_closing_curly_brace(self.toks)?; let vec = peek_until_closing_curly_brace(self.toks)?;
let peek_counter = vec.len(); let peek_counter = vec.len();

104
tests/min-max.rs Normal file
View File

@ -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);