@mdo

Diffing Sass maps

I’ve been working on some baseline Sass tests for Bootstrap and had the need to diff two different Sass maps—the expected and the actual. Bootstrap provides thousands of default variables and several default Sass maps that are extended and reused under the premise of improved customization. What we’ve run into is some Sass limitations that forced us to make a few architectural changes.

The main problem isn’t the limitation—that once a variable or map has been used, it cannot be modified later—but rather that we weren’t testing for this enough. With that in mind, I set out to try some homegrown tests for how to compare what we expected to happen in our Ssas with what’s actually happening.

This post is a rundown of what I tried out and where I ended up. To start, let’s assume these are our two maps.

// What we want to see
$expected-colors: (
  "primary": "",
  "secondary": "",
  "success": "",
  "info": "",
  "warning": "",
  "danger": "",
  "light": "",
  "dark": "",
  "custom": ""
);

// What we're actually seeing
$colors: (
  "primary": "",
  "secondary": "",
  "success": "",
  "info": "",
  "warning": "",
  "danger": ""
);

Compare via @debug

Intitially I just needed to know that two maps—the expected and the actual—matched without any warning or error in the CLI. That’s easy enough with @debug and showing the map keys.

@debug "The following maps should be the same, ending with the custom key.";
@debug map.keys($expected-colors);
@debug map.keys($colors);

Which outputs the following:

Debug: The following maps should be the same, ending with the custom key.
Debug: "primary", "secondary", "success", "info", "warning", "danger", "light", "dark", "custom"
Debug: "primary", "secondary", "success", "info", "warning", "danger"

This is helpful for manually checking if something is looking right, but not the best for proactively flagging an issue mid pull request.

Adding in @warn

Making use of @warn—which one could configure to fail a CI build—allows us to compile the entire Sass file and output all the issues to the CLI. @error wouldn’t work for this as it’d stop the compiling at the first error.

Here I setup an actual condtion—these two maps must equal one another. If they don’t, warn me and show me the difference.

@if map.keys($colors) != map.keys($expected-colors) {
  @warn "Keys in $colors don't match expected output.";
  @debug "Expected: " + map.keys($expected-colors);
  @debug "Actual:   " + map.keys($colors);
}

Which outputs the following:

Warning: Keys in $colors don't match expected output.
  test/index.scss 31:3  root stylesheet

Debug: Expected: "primary", "secondary", "success", "info", "warning", "danger", "light", "dark", "custom"
Debug: Actual:   "primary", "secondary", "success", "info", "warning", "danger"

This is great, but I still have to manually make the comparison for what’s different between expected and actual. To improve that, I needed to diff the two maps somehow.

Creating diff-map

There’s no built-in map-diff or map.diff feature in Sass, so I wrote a basic function to get me close enough. This diff-map function removes the keys found in both maps and shows me what’s left over in the expected map.

@function diff-map($actual, $expected) {
  $actual-keys: map.keys($actual);
  @each $key in $actual-keys {
    $expected: map.remove($expected, $key);
  }
  @return $expected;
}

@debug diff-map($colors, $expected-colors);

Which outputs the following:

Debug: ("light": "", "dark": "", "custom": "")

Okay, now we’re getting somewhere. This doesn’t account for a situation though in which we have more keys in the output than the expected map.

Improving the function

Next I updated the function to ensure a diff happens in either direction. I added some @if/@else statements to ensure that the larger map (determined via length($map)) has it’s duplicate keys removed and we don’t end up with negative numbers. From there, we use @debug to print the number of remaining keys and list them.

This iteration of the function isn’t the prettiest, but it’s effective. Side note: there’s no real way to get a Sass map’s “name”, just it’s array of keys. Super sad.

@function diff-map($actual, $expected) {
  @if $actual == $expected {
    @debug "Maps have no differences.";
  } @else {
    @if length($actual) > length($expected) {
      $difference: length($actual) - length($expected);
      $keyword: if($difference > 1, "keys", "key");
      @debug "Actual has #{$difference} more #{$keyword} than expected.";
    } @else if length($actual) < length($expected) {
      $difference: length($expected) - length($actual);
      $keyword: if($difference > 1, "keys", "key");
      @debug "Actual has #{$difference} fewer #{$keyword} than expected.";
    }

    $actual-keys: map.keys($actual);
    @each $key in $actual-keys {
      $expected: map.remove($expected, $key);
    }
    @return $expected;
  }
}

@debug diff-map($colors, $expected-colors);

Which outputs the following:

Warning: Keys in $colors don't match expected output.
    test/index.scss 154:3  root stylesheet

test/index.scss:188 Debug: Actual has 3 fewer keys than expected.
test/index.scss:199 Debug: ("light": "", "dark": "", "custom": "")

The debug statements in the CLI tell me that there are three fewer keys than expected and succinctly lists them out.

I’m not done with this yet, but wanted to at least share what I stumbled into. Ideally this function could be improved to also account for more situations in which maps can be compared (e.g., actual and expected have unique keys).

Check it out in this pull request in the Bootstrap repo and let me know what you think!