Skip to content

Commit

Permalink
Merge pull request #1102 from sass/feature.nested-maps
Browse files Browse the repository at this point in the history
Merge support for nested map functions
  • Loading branch information
nex3 authored Oct 7, 2020
2 parents bdef3ac + c14cb73 commit b5d5f95
Show file tree
Hide file tree
Showing 15 changed files with 262 additions and 11 deletions.
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,74 @@
## 1.27.0

* Adds an overload to `map.merge()` that supports merging a nested map.

`map.merge($map1, $keys..., $map2)`: The `$keys` form a path to the nested map
in `$map1`, into which `$map2` gets merged.

See [the Sass documentation][map-merge] for more details.

[map-merge]: https://sass-lang.com/documentation/modules/map#merge

* Adds an overloaded `map.set()` function.

`map.set($map, $key, $value)`: Adds to or updates `$map` with the specified
`$key` and `$value`.

`map.set($map, $keys..., $value)`: Adds to or updates a map that is nested
within `$map`. The `$keys` form a path to the nested map in `$map`, into
which `$value` is inserted.

See [the Sass documentation][map-set] for more details.

[map-set]: https://sass-lang.com/documentation/modules/map#set

* Add support for nested maps to `map.get()`.
For example, `map.get((a: (b: (c: d))), a, b, c)` would return `d`.
See [the documentation][map-get] for more details.

[map-get]: https://sass-lang.com/documentation/modules/map#get

* Add support for nested maps in `map.has-key`.
For example, `map.has-key((a: (b: (c: d))), a, b, c)` would return true.
See [the documentation][map-has-key] for more details.

[map-has-key]: https://sass-lang.com/documentation/modules/map#has-key

* Add a `map.deep-merge()` function. This works like `map.merge()`, except that
nested map values are *also* recursively merged. For example:

```
map.deep-merge(
(color: (primary: red, secondary: blue),
(color: (secondary: teal)
) // => (color: (primary: red, secondary: teal))
```

See [the Sass documentation][map-deep-merge] for more details.

[map-deep-merge]: https://sass-lang.com/documentation/modules/map#deep-merge

* Add a `map.deep-remove()` function. This allows you to remove keys from
nested maps by passing multiple keys. For example:

```
map.deep-remove(
(color: (primary: red, secondary: blue)),
color, primary
) // => (color: (secondary: blue))
```

See [the Sass documentation][map-deep-remove] for more details.

[map-deep-remove]: https://sass-lang.com/documentation/modules/map#deep-remove

### Dart API

* Add a `Value.tryMap()` function which returns the `Value` as a `SassMap` if
it's a valid map, or `null` otherwise. This allows function authors to safely
retrieve maps even if they're internally stored as empty lists, without having
to catch exceptions from `Value.assertMap()`.

## 1.26.12

* Fix a bug where nesting properties beneath a Sass-syntax custom property
Expand Down
179 changes: 169 additions & 10 deletions lib/src/functions/map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'dart:collection';
import 'package:collection/collection.dart';

import '../callable.dart';
import '../exception.dart';
import '../module/built_in.dart';
import '../value.dart';

Expand All @@ -21,19 +22,89 @@ final global = UnmodifiableListView([
]);

/// The Sass map module.
final module = BuiltInModule("map",
functions: [_get, _merge, _remove, _keys, _values, _hasKey]);
final module = BuiltInModule("map", functions: [
_get,
_set,
_merge,
_remove,
_keys,
_values,
_hasKey,
_deepMerge,
_deepRemove
]);

final _get = _function("get", r"$map, $key", (arguments) {
final _get = _function("get", r"$map, $key, $keys...", (arguments) {
var map = arguments[0].assertMap("map");
var key = arguments[1];
return map.contents[key] ?? sassNull;
var keys = [arguments[1], ...arguments[2].asList];
for (var key in keys.take(keys.length - 1)) {
var value = map.contents[key];
if (value is SassMap) {
map = value;
} else {
return sassNull;
}
}
return map.contents[keys.last] ?? sassNull;
});

final _set = BuiltInCallable.overloadedFunction("set", {
r"$map, $key, $value": (arguments) {
var map = arguments[0].assertMap("map");
return _modify(map, [arguments[1]], (_) => arguments[2]);
},
r"$map, $args...": (arguments) {
var map = arguments[0].assertMap("map");
var args = arguments[1].asList;
if (args.isEmpty) {
throw SassScriptException("Expected \$args to contain a key.");
} else if (args.length == 1) {
throw SassScriptException("Expected \$args to contain a value.");
}
return _modify(map, args.sublist(0, args.length - 1), (_) => args.last);
},
});

final _merge = BuiltInCallable.overloadedFunction("merge", {
r"$map1, $map2": (arguments) {
var map1 = arguments[0].assertMap("map1");
var map2 = arguments[1].assertMap("map2");
return SassMap({...map1.contents, ...map2.contents});
},
r"$map1, $args...": (arguments) {
var map1 = arguments[0].assertMap("map1");
var args = arguments[1].asList;
if (args.isEmpty) {
throw SassScriptException("Expected \$args to contain a key.");
} else if (args.length == 1) {
throw SassScriptException("Expected \$args to contain a map.");
}
var map2 = args.last.assertMap("map2");
return _modify(map1, args.take(args.length - 1), (oldValue) {
var nestedMap = oldValue?.tryMap();
if (nestedMap == null) return map2;
return SassMap({...nestedMap.contents, ...map2.contents});
});
},
});

final _merge = _function("merge", r"$map1, $map2", (arguments) {
final _deepMerge = _function("deep-merge", r"$map1, $map2", (arguments) {
var map1 = arguments[0].assertMap("map1");
var map2 = arguments[1].assertMap("map2");
return SassMap({...map1.contents, ...map2.contents});
return _deepMergeImpl(map1, map2);
});

final _deepRemove =
_function("deep-remove", r"$map, $key, $keys...", (arguments) {
var map = arguments[0].assertMap("map");
var keys = [arguments[1], ...arguments[2].asList];
return _modify(map, keys.take(keys.length - 1), (value) {
var nestedMap = value?.tryMap();
if (nestedMap?.contents?.containsKey(keys.last) ?? false) {
return SassMap(Map.of(nestedMap.contents)..remove(keys.last));
}
return value;
});
});

final _remove = BuiltInCallable.overloadedFunction("remove", {
Expand Down Expand Up @@ -67,12 +138,100 @@ final _values = _function(
(arguments) => SassList(
arguments[0].assertMap("map").contents.values, ListSeparator.comma));

final _hasKey = _function("has-key", r"$map, $key", (arguments) {
final _hasKey = _function("has-key", r"$map, $key, $keys...", (arguments) {
var map = arguments[0].assertMap("map");
var key = arguments[1];
return SassBoolean(map.contents.containsKey(key));
var keys = [arguments[1], ...arguments[2].asList];
for (var key in keys.take(keys.length - 1)) {
var value = map.contents[key];
if (value is SassMap) {
map = value;
} else {
return sassFalse;
}
}
return SassBoolean(map.contents.containsKey(keys.last));
});

/// Updates the specified value in [map] by applying the [modify] callback to
/// it, then returns the resulting map.
///
/// If more than one key is provided, this means the map targeted for update is
/// nested within [map]. The multiple [keys] form a path of nested maps that
/// leads to the targeted map. If any value along the path is not a map, and
/// `modify(null)` returns null, this inserts a new map at that key and
/// overwrites the current value. Otherwise, this fails and returns [map] with
/// no changes.
///
/// If no keys are provided, this passes [map] directly to modify and returns
/// the result.
Value _modify(SassMap map, Iterable<Value> keys, Value modify(Value old)) {
var keyIterator = keys.iterator;
SassMap _modifyNestedMap(SassMap map, [Value newValue]) {
var mutableMap = Map.of(map.contents);
var key = keyIterator.current;

if (!keyIterator.moveNext()) {
mutableMap[key] = newValue ?? modify(mutableMap[key]);
return SassMap(mutableMap);
}

var nestedMap = mutableMap[key]?.tryMap();
if (nestedMap == null) {
// We pass null to `modify` here to indicate there's no existing value.
newValue = modify(null);
if (newValue == null) return SassMap(mutableMap);
}

nestedMap ??= const SassMap.empty();
mutableMap[key] = _modifyNestedMap(nestedMap, newValue);
return SassMap(mutableMap);
}

return keyIterator.moveNext() ? _modifyNestedMap(map) : modify(map);
}

/// Merges [map1] and [map2], with values in [map2] taking precedence.
///
/// If both [map1] and [map2] have a map value associated with the same key,
/// this recursively merges those maps as well.
SassMap _deepMergeImpl(SassMap map1, SassMap map2) {
if (map2.contents.isEmpty) return map1;

// Avoid making a mutable copy of `map2` if it would totally overwrite `map1`
// anyway.
var mutable = false;
var result = map2.contents;
void _ensureMutable() {
if (mutable) return;
mutable = true;
result = Map.of(result);
}

// Because values in `map2` take precedence over `map1`, we just check if any
// entires in `map1` don't have corresponding keys in `map2`, or if they're
// maps that need to be merged in their own right.
map1.contents.forEach((key, value) {
var resultValue = result[key];
if (resultValue == null) {
_ensureMutable();
result[key] = value;
} else {
var resultMap = resultValue.tryMap();
var valueMap = value.tryMap();

if (resultMap != null && valueMap != null) {
var merged = _deepMergeImpl(valueMap, resultMap);
if (identical(merged, resultMap)) return;

_ensureMutable();
result[key] = merged;
}
}
});

return mutable ? SassMap(result) : map2;
}

/// Like [new BuiltInCallable.function], but always sets the URL to `sass:map`.
BuiltInCallable _function(
String name, String arguments, Value callback(List<Value> arguments)) =>
Expand Down
2 changes: 2 additions & 0 deletions lib/src/value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ abstract class Value implements ext.Value {
SassMap assertMap([String name]) =>
throw _exception("$this is not a map.", name);

SassMap tryMap() => null;

SassNumber assertNumber([String name]) =>
throw _exception("$this is not a number.", name);

Expand Down
4 changes: 4 additions & 0 deletions lib/src/value/external/value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ abstract class Value {
/// (without the `$`). It's used for error reporting.
SassMap assertMap([String name]);

/// Returns [this] as a [SassMap] if it is one (including empty lists, which
/// count as empty maps) or returns `null` if it's not.
SassMap tryMap();

/// Throws a [SassScriptException] if [this] isn't a number.
///
/// If this came from a function argument, [name] is the argument name
Expand Down
2 changes: 2 additions & 0 deletions lib/src/value/list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class SassList extends Value implements ext.SassList {
SassMap assertMap([String name]) =>
asList.isEmpty ? const SassMap.empty() : super.assertMap(name);

SassMap tryMap() => asList.isEmpty ? const SassMap.empty() : null;

bool operator ==(Object other) =>
(other is SassList &&
other.separator == separator &&
Expand Down
2 changes: 2 additions & 0 deletions lib/src/value/map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class SassMap extends Value implements ext.SassMap {

SassMap assertMap([String name]) => this;

SassMap tryMap() => this;

bool operator ==(Object other) =>
(other is SassMap && mapEquals(other.contents, contents)) ||
(contents.isEmpty && other is SassList && other.asList.isEmpty);
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: sass
version: 1.26.12
version: 1.27.0
description: A Sass implementation in Dart.
author: Sass Team
homepage: https://github.com/sass/dart-sass
Expand Down
2 changes: 2 additions & 0 deletions test/dart_api/value/boolean_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ void main() {
expect(value.assertColor, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
expect(value.tryMap(), isNull);
expect(value.assertNumber, throwsSassScriptException);
expect(value.assertString, throwsSassScriptException);
});
Expand All @@ -56,6 +57,7 @@ void main() {
expect(value.assertColor, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
expect(value.tryMap(), isNull);
expect(value.assertNumber, throwsSassScriptException);
expect(value.assertString, throwsSassScriptException);
});
Expand Down
1 change: 1 addition & 0 deletions test/dart_api/value/color_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ void main() {
expect(value.assertBoolean, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
expect(value.tryMap(), isNull);
expect(value.assertNumber, throwsSassScriptException);
expect(value.assertString, throwsSassScriptException);
});
Expand Down
1 change: 1 addition & 0 deletions test/dart_api/value/function_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ void main() {
expect(value.assertBoolean, throwsSassScriptException);
expect(value.assertColor, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
expect(value.tryMap(), isNull);
expect(value.assertNumber, throwsSassScriptException);
expect(value.assertString, throwsSassScriptException);
});
Expand Down
3 changes: 3 additions & 0 deletions test/dart_api/value/list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ void main() {
expect(value.assertColor, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
expect(value.tryMap(), isNull);
expect(value.assertNumber, throwsSassScriptException);
expect(value.assertString, throwsSassScriptException);
});
Expand Down Expand Up @@ -140,6 +141,7 @@ void main() {
expect(value.assertColor, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
expect(value.tryMap(), isNull);
expect(value.assertNumber, throwsSassScriptException);
expect(value.assertString, throwsSassScriptException);
});
Expand Down Expand Up @@ -167,6 +169,7 @@ void main() {

test("counts as an empty map", () {
expect(value.assertMap().contents, isEmpty);
expect(value.tryMap().contents, isEmpty);
});

test("isn't any other type", () {
Expand Down
1 change: 1 addition & 0 deletions test/dart_api/value/map_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ void main() {

test("is a map", () {
expect(value.assertMap(), equals(value));
expect(value.tryMap(), equals(value));
});

test("isn't any other type", () {
Expand Down
Loading

0 comments on commit b5d5f95

Please sign in to comment.