diff --git a/CHANGELOG.md b/CHANGELOG.md index b691eed..eaa08f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ --> +# TBD + +- implement builtin map-module function `map.deep-merge(..)` + # 0.12.3 No visible changes for users of the `grass` crate diff --git a/crates/compiler/src/builtin/functions/map.rs b/crates/compiler/src/builtin/functions/map.rs index d42105a..129a205 100644 --- a/crates/compiler/src/builtin/functions/map.rs +++ b/crates/compiler/src/builtin/functions/map.rs @@ -3,53 +3,26 @@ use crate::builtin::builtin_imports::*; pub(crate) fn map_get(mut args: ArgumentResult, visitor: &mut Visitor) -> SassResult { args.max_args(2)?; let key = args.get_err(1, "key")?; - let map = match args.get_err(0, "map")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let map = args + .get_err(0, "map")? + .assert_map_with_name("map", args.span())?; Ok(map.get(&key).unwrap_or(Value::Null)) } pub(crate) fn map_has_key(mut args: ArgumentResult, visitor: &mut Visitor) -> SassResult { args.max_args(2)?; let key = args.get_err(1, "key")?; - let map = match args.get_err(0, "map")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let map = args + .get_err(0, "map")? + .assert_map_with_name("map", args.span())?; Ok(Value::bool(map.get(&key).is_some())) } pub(crate) fn map_keys(mut args: ArgumentResult, visitor: &mut Visitor) -> SassResult { args.max_args(1)?; - let map = match args.get_err(0, "map")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let map = args + .get_err(0, "map")? + .assert_map_with_name("map", args.span())?; Ok(Value::List( map.keys(), ListSeparator::Comma, @@ -59,18 +32,9 @@ pub(crate) fn map_keys(mut args: ArgumentResult, visitor: &mut Visitor) -> SassR pub(crate) fn map_values(mut args: ArgumentResult, visitor: &mut Visitor) -> SassResult { args.max_args(1)?; - let map = match args.get_err(0, "map")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let map = args + .get_err(0, "map")? + .assert_map_with_name("map", args.span())?; Ok(Value::List( map.values(), ListSeparator::Comma, @@ -85,31 +49,13 @@ pub(crate) fn map_merge(mut args: ArgumentResult, visitor: &mut Visitor) -> Sass let map2_position = args.len().saturating_sub(1); - let mut map1 = match args.get_err(0, "map1")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map1: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let mut map1 = args + .get_err(0, "map1")? + .assert_map_with_name("map1", args.span())?; - let map2 = match args.get_err(map2_position, "map2")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map2: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let map2 = args + .get_err(map2_position, "map2")? + .assert_map_with_name("map2", args.span())?; let keys = args.get_variadic()?; @@ -156,18 +102,9 @@ pub(crate) fn map_merge(mut args: ArgumentResult, visitor: &mut Visitor) -> Sass } pub(crate) fn map_remove(mut args: ArgumentResult, visitor: &mut Visitor) -> SassResult { - let mut map = match args.get_err(0, "map")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let mut map = args + .get_err(0, "map")? + .assert_map_with_name("map", args.span())?; let keys = args.get_variadic()?; for key in keys { map.remove(&key); @@ -179,18 +116,9 @@ pub(crate) fn map_set(mut args: ArgumentResult, visitor: &mut Visitor) -> SassRe let key_position = args.len().saturating_sub(2); let value_position = args.len().saturating_sub(1); - let mut map = match args.get_err(0, "map")? { - Value::Map(m) => m, - Value::List(v, ..) if v.is_empty() => SassMap::new(), - Value::ArgList(v) if v.is_empty() => SassMap::new(), - v => { - return Err(( - format!("$map: {} is not a map.", v.inspect(args.span())?), - args.span(), - ) - .into()) - } - }; + let mut map = args + .get_err(0, "map")? + .assert_map_with_name("map", args.span())?; let key = Spanned { node: args.get_err(key_position, "key")?, diff --git a/crates/compiler/src/builtin/modules/map.rs b/crates/compiler/src/builtin/modules/map.rs index 7dccb7b..95bcbfe 100644 --- a/crates/compiler/src/builtin/modules/map.rs +++ b/crates/compiler/src/builtin/modules/map.rs @@ -1,8 +1,58 @@ +use crate::builtin::builtin_imports::*; + use crate::builtin::{ map::{map_get, map_has_key, map_keys, map_merge, map_remove, map_set, map_values}, modules::Module, }; +fn deep_merge_impl(map1: SassMap, map2: SassMap) -> SassMap { + if map1.is_empty() { + return map2; + } + if map2.is_empty() { + return map1; + } + + let mut result = map1; + + for (key, value) in map2 { + let result_map = result.get_ref(&key.node).and_then(Value::try_map); + + match result_map { + Some(result_map) => match value.try_map() { + Some(value_map) => { + let merged = deep_merge_impl(result_map, value_map); + result.insert(key, Value::Map(merged)); + } + None => { + result.insert(key, value); + } + }, + None => { + result.insert(key, value); + } + } + } + + result +} + +fn deep_merge(mut args: ArgumentResult, _: &mut Visitor) -> SassResult { + args.max_args(2)?; + + let span = args.span(); + + let map1 = args + .get_err(0, "map1")? + .assert_map_with_name("map1", span)?; + + let map2 = args + .get_err(1, "map2")? + .assert_map_with_name("map2", span)?; + + Ok(Value::Map(deep_merge_impl(map1, map2))) +} + pub(crate) fn declare(f: &mut Module) { f.insert_builtin("get", map_get); f.insert_builtin("has-key", map_has_key); @@ -11,4 +61,5 @@ pub(crate) fn declare(f: &mut Module) { f.insert_builtin("remove", map_remove); f.insert_builtin("values", map_values); f.insert_builtin("set", map_set); + f.insert_builtin("deep-merge", deep_merge); } diff --git a/crates/compiler/src/value/mod.rs b/crates/compiler/src/value/mod.rs index bf84c8d..b6e6780 100644 --- a/crates/compiler/src/value/mod.rs +++ b/crates/compiler/src/value/mod.rs @@ -171,6 +171,23 @@ impl Value { } } + pub fn assert_map_with_name(self, name: &str, span: Span) -> SassResult { + match self { + Value::Map(m) => Ok(m), + Value::List(v, ..) if v.is_empty() => Ok(SassMap::new()), + Value::ArgList(v) if v.is_empty() => Ok(SassMap::new()), + _ => Err(( + format!( + "${name}: {} is not a map.", + self.inspect(span)?, + name = name, + ), + span, + ) + .into()), + } + } + pub fn assert_string_with_name( self, name: &str, @@ -303,6 +320,15 @@ impl Value { } } + pub fn try_map(&self) -> Option { + match &self { + Value::Map(m) => Some(m.clone()), + Value::List(v, ..) if v.is_empty() => Some(SassMap::new()), + Value::ArgList(v) if v.is_empty() => Some(SassMap::new()), + _ => None, + } + } + pub fn bool(b: bool) -> Self { if b { Value::True diff --git a/crates/lib/tests/map-module.rs b/crates/lib/tests/map-module.rs new file mode 100644 index 0000000..eb5f9cd --- /dev/null +++ b/crates/lib/tests/map-module.rs @@ -0,0 +1,135 @@ +#[macro_use] +mod macros; + +test!( + map_set_nested_empty, + "@use 'sass:map'; a {b: inspect(map.set((c: ()), c, d, e, f))}", + "a {\n b: (c: (d: (e: f)));\n}\n" +); +test!( + map_set_update_existing, + "@use 'sass:map'; a {b: inspect(map.set((c: (d: e)), c, d, f))}", + "a {\n b: (c: (d: f));\n}\n" +); +test!( + map_set_new_key, + "@use 'sass:map'; a {b: inspect(map.set((c: (d: e)), c, f, g))}", + "a {\n b: (c: (d: e, f: g));\n}\n" +); +test!( + map_set_value_is_not_map, + "@use 'sass:map'; a {b: inspect(map.set((c: 1), c, d, f))}", + "a {\n b: (c: (d: f));\n}\n" +); +test!( + map_merge_merge_into_map_with_many_keys, + r#" + @use "sass:map"; + + $fonts: ( + "Helvetica": ( + "weights": ( + "regular": 400, + "medium": 500, + "bold": 700 + ) + ) + ); + + a { + color: inspect(map.merge($fonts, "Helvetica", "weights", "regular", (a: 300))); + }"#, + "a {\n color: (\"Helvetica\": (\"weights\": (\"regular\": (a: 300), \"medium\": 500, \"bold\": 700)));\n}\n" +); +test!( + map_merge_nested, + r#" + @use "sass:map"; + + $fonts: ( + "Helvetica": ( + "weights": ( + "regular": 400, + "medium": 500, + "bold": 700 + ) + ) + ); + + a { + color: inspect(map.set($fonts, "Helvetica", "weights", "regular", 300)); + }"#, + "a {\n color: (\"Helvetica\": (\"weights\": (\"regular\": 300, \"medium\": 500, \"bold\": 700)));\n}\n" +); +test!( + deep_merge_no_nesting, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge($map1: (c: d), $map2: (1: 2))); + }"#, + "a {\n color: (c: d, 1: 2);\n}\n" +); +test!( + deep_merge_positional, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge((a: b), (c: d))); + }"#, + "a {\n color: (a: b, c: d);\n}\n" +); +test!( + deep_merge_empty_maps, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge((), ())); + }"#, + "a {\n color: ();\n}\n" +); +test!( + deep_merge_empty_maps_bracketed_list, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge([], [])); + }"#, + "a {\n color: ();\n}\n" +); +test!( + deep_merge_empty_first, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge((a: b), ())); + }"#, + "a {\n color: (a: b);\n}\n" +); +test!( + deep_merge_empty_second, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge((), (a: b))); + }"#, + "a {\n color: (a: b);\n}\n" +); +test!( + deep_merge_empty_deep, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge((c: (d: e)), (c: ()))); + }"#, + "a {\n color: (c: (d: e));\n}\n" +); +test!( + deep_merge_empty_bracketed_list_deep, + r#"@use "sass:map"; + + a { + color: inspect(map.deep-merge((c: (d: e)), (c: []))); + }"#, + "a {\n color: (c: (d: e));\n}\n" +); diff --git a/crates/lib/tests/map.rs b/crates/lib/tests/map.rs index a991c96..967a1c2 100644 --- a/crates/lib/tests/map.rs +++ b/crates/lib/tests/map.rs @@ -120,66 +120,6 @@ test!( "a {b: inspect(map-merge((c: (d: (e: (f: (g: h))))), c, d, e, f, (g: 1)))}", "a {\n b: (c: (d: (e: (f: (g: 1)))));\n}\n" ); -test!( - map_set_nested_empty, - "@use 'sass:map'; a {b: inspect(map.set((c: ()), c, d, e, f))}", - "a {\n b: (c: (d: (e: f)));\n}\n" -); -test!( - map_set_update_existing, - "@use 'sass:map'; a {b: inspect(map.set((c: (d: e)), c, d, f))}", - "a {\n b: (c: (d: f));\n}\n" -); -test!( - map_set_new_key, - "@use 'sass:map'; a {b: inspect(map.set((c: (d: e)), c, f, g))}", - "a {\n b: (c: (d: e, f: g));\n}\n" -); -test!( - map_set_value_is_not_map, - "@use 'sass:map'; a {b: inspect(map.set((c: 1), c, d, f))}", - "a {\n b: (c: (d: f));\n}\n" -); -test!( - map_merge_merge_into_map_with_many_keys, - r#" - @use "sass:map"; - - $fonts: ( - "Helvetica": ( - "weights": ( - "regular": 400, - "medium": 500, - "bold": 700 - ) - ) - ); - - a { - color: inspect(map.merge($fonts, "Helvetica", "weights", "regular", (a: 300))); - }"#, - "a {\n color: (\"Helvetica\": (\"weights\": (\"regular\": (a: 300), \"medium\": 500, \"bold\": 700)));\n}\n" -); -test!( - map_merge_nested, - r#" - @use "sass:map"; - - $fonts: ( - "Helvetica": ( - "weights": ( - "regular": 400, - "medium": 500, - "bold": 700 - ) - ) - ); - - a { - color: inspect(map.set($fonts, "Helvetica", "weights", "regular", 300)); - }"#, - "a {\n color: (\"Helvetica\": (\"weights\": (\"regular\": 300, \"medium\": 500, \"bold\": 700)));\n}\n" -); error!( map_merge_map1_non_map, "a {\n color: map-merge(foo, (a: b));\n}\n", "Error: $map1: foo is not a map." @@ -241,7 +181,6 @@ test!( "a {\n color: 1, 2;\n}\n" ); test!( - #[ignore = "blocked on rewriting inspect"] map_inspect_comma_separated_list_as_key, "a {\n color: inspect(((1, 2): 3));\n}\n", "a {\n color: ((1, 2): 3);\n}\n"