Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions lib/attrsets.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

let
inherit (builtins) head length;
inherit (lib.asserts) assertMsg;
inherit (lib.generators) toPretty;
inherit (lib.trivial) mergeAttrs warn;
inherit (lib.strings) concatStringsSep concatMapStringsSep escapeNixIdentifier sanitizeDerivationName;
inherit (lib.lists) foldr foldl' concatMap elemAt all partition groupBy take foldl;
Expand Down Expand Up @@ -1726,6 +1728,75 @@ rec {
if path == [] then "<root attribute path>"
else concatMapStringsSep "." escapeNixIdentifier path;

/**
Turns a list of strings into a human-readable description of those
strings represented as an attribute path. The result of this function is
not intended to be machine-readable.
Create a new attribute set with `value` set at the nested attribute location specified in `attrPath`.

# Inputs

`context` (String)

: The error context in case a tag doesn't have a handler or the value is not valid.

`value` (AttrsOf Any)

: The value to match.

`handlers` (AttrsOf (Any -> Any))

: An attribute set containing a handler function for each case.

# Type

```
matchAttrTag :: String -> AttrsOf Any -> AttrsOf (Any -> Any) -> Any
```

# Examples
:::{.example}
## `lib.attrsets.matchAttrTag` usage example

```nix
matchAttrTag "test" { n = 10; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
}
=> "It's the number 10"

matchAttrTag "test" { s = "Paul"; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
}
=> "It's the string Paul"

matchAttrTag "test" { unknown = null; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
}
=> error: test: No handler for tag "unknown"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"test" doesn't seem like a great example. It'd help to have a more realistic use case. When it's a realistic example, it's easier to see what's expected from the context.

When this error occurs in the context of a module that defines an option of type attrTag ..., this case will only occur due to programming error, and I don't think the module author should have the foresight to clarify that in the context.

In other cases, you probably do want the context to be quite general.
Maybe we should have another function that takes an option instead of its value? Doesn't work for attrTags when used as part of type constructors like attrsOf though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the merged result of attrTag is the matching function itself 🤔

config.some.option {
  n = number: "It's the number ${toString number}";
  s = str: "It's the string ${str}";
}

This would be doable effectively in a backwards-compatible way using __functor! And this way we should be able to get a good error message for free. Not a big fan of __functor in general, but in this case I'm really wondering..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my use case, settings, I need to not have it:

       … while evaluating attribute '__functor'

error: cannot convert a function to JSON

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it also wouldn't be very backwards compatible, because any code that asserts for the attribute set to only have one attribute would fail.


matchAttrTag "test" { n = 10; s = "Paul"; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
}
=> error: test: Value has not exactly one tag: [ "n" "s" ]
```

:::
*/
matchAttrTag =
context: value: handlers:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
context: value: handlers:
context: handlers: value:

Often, the handlers are more part of the program than part of the data, so arranging them like this lets us take advantage of currying, in things like map, pipes, or eta-reduced function definitions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the most common case is to not curry tbh, and you can still use flip if you need it. With the value: handlers: order we're also mirroring how matching looks like in many other languages:

match value {
  Foo { .. } => 0
  Bar { .. } => 1
}
case value of
  Foo { .. } -> 0
  Bar { .. } -> 1

So I'd really prefer leaving it like that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The annoying thing is that if you do want to lib.flip, you have to write it like flip (matchAttrTag "context") [...] which looks a bit funny to me

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess if we go for #304328 (comment) this won't be a problem anymore :D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pipe friendly:

map (matchAttrTag "context") myList

and

value |> matchAttrTag "context" {
  ...
}

Not pipe friendly:

map (flip (matchAttrTag "context")) myList

and

matchAttrTag "context" value {
  ...
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, fwiw, avoiding flip avoids a runtime cost.

let
tagList = attrNames value;
# `lib.types.attrTag` guarantees that the resulting value only has exactly one key
tag = head tagList;
handler = handlers.${tag} or (throw "${context}: No handler for tag \"${tag}\"");
in
assert assertMsg (length tagList == 1)
"${context}: Value has not exactly one tag: ${toPretty { multiline = false; } tagList}";
handler value.${tag};

/**
Get a package output.
Expand Down
2 changes: 1 addition & 1 deletion lib/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ let
mapAttrs' mapAttrsToList attrsToList concatMapAttrs mapAttrsRecursive
mapAttrsRecursiveCond genAttrs isDerivation toDerivation optionalAttrs
zipAttrsWithNames zipAttrsWith zipAttrs recursiveUpdateUntil
recursiveUpdate matchAttrs mergeAttrsList overrideExisting showAttrPath getOutput
recursiveUpdate matchAttrs mergeAttrsList overrideExisting showAttrPath matchAttrTag getOutput
getBin getLib getDev getMan chooseDevOutputs zipWithNames zip
recurseIntoAttrs dontRecurseIntoAttrs cartesianProductOfSets
updateManyAttrsByPath;
Expand Down
32 changes: 32 additions & 0 deletions lib/tests/misc.nix
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ let
makeOverridable
mapAttrs
matchAttrs
matchAttrTag
mergeAttrs
meta
mkOption
Expand Down Expand Up @@ -1108,6 +1109,37 @@ runTests {
attrsToList { someFunc= a: a + 1;}
);

testMatchAttrTagExample1 = {
expr = matchAttrTag "test" { n = 10; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
};
expected = "It's the number 10";
};

testMatchAttrTagExample2 = {
expr = matchAttrTag "test" { s = "Paul"; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
};
expected = "It's the string Paul";
};

testMatchAttrTagExample3 = testingThrow (
matchAttrTag "test" { unknown = null; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
}
);

testMatchAttrTagExample4 = testingThrow (
matchAttrTag "test" { n = 10; s = "Paul"; } {
n = number: "It's the number ${toString number}";
s = str: "It's the string ${str}";
}
);


# GENERATORS
# these tests assume attributes are converted to lists
# in alphabetical order
Expand Down