# Add this to docs somewhere: If you pass the same arguments to the same functions, you will get the same result
{ lib }:
let
  # TODO: Add builtins.traceVerbose or so to all the operations for debugging
  # TODO: Document limitation that empty directories won't be included
  # TODO: Point out that equality comparison using `==` doesn't quite work because there's multiple representations for all files in a directory: "directory" and `readDir path`.
  # TODO: subset and superset check functions. Can easily be implemented with `difference` and `isEmpty`
  # TODO: Write down complexity of each operation, most should be O(1)!
  # TODO: Implement an operation for optionally including a file if it exists.
  # TODO: Derive property tests from https://en.wikipedia.org/wiki/Algebra_of_sets

  inherit (builtins)
    isAttrs
    isString
    isPath
    isList
    typeOf
    readDir
    match
    pathExists
    seq
    ;

  inherit (lib.trivial)
    mapNullable
    ;

  inherit (lib.lists)
    head
    tail
    foldl'
    range
    length
    elemAt
    all
    imap0
    drop
    commonPrefix
    ;

  inherit (lib.strings)
    isCoercibleToString
    ;

  inherit (lib.attrsets)
    mapAttrs
    attrNames
    attrValues
    ;

  inherit (lib.filesystem)
    pathType
    ;

  inherit (lib.sources)
    cleanSourceWith
    ;

  inherit (lib.path)
    append
    deconstruct
    construct
    hasPrefix
    removePrefix
    ;

  inherit (lib.path.components)
    fromSubpath
    ;

  # Internal file set structure:
  #
  # # A set of files
  # <fileset> = {
  #   _type = "fileset";
  #
  #   # The base path, only files under this path can be represented
  #   # Always a directory
  #   _base = <path>;
  #
  #   # A tree representation of all included files
  #   _tree = <tree>;
  # };
  #
  # # A directory entry value
  # <tree> =
  #   # A nested directory
  #   <directory>
  #
  #   # A nested file
  #   | <file>
  #
  #   # A removed file or directory
  #   # This is represented like this instead of removing the entry from the attribute set because:
  #   # - It improves laziness
  #   # - It allows keeping the attribute set as a `builtins.readDir` cache
  #   | null
  #
  # # A directory
  # <directory> =
  #   # The inclusion state for every directory entry
  #   { <name> = <tree>; }
  #
  #   # All files in a directory, recursively.
  #   # Semantically this is equivalent to `builtins.readDir path`, but lazier, because
  #   # operations that don't require the entry listing can avoid it.
  #   # This string is chosen to be compatible with `builtins.readDir` for a simpler implementation
  #   "directory";
  #
  # # A file
  # <file> =
  #   # A file with this filetype
  #   # These strings match `builtins.readDir` for a simpler implementation
  #   "regular" | "symlink" | "unknown"

  # Create a fileset structure
  # Type: Path -> <tree> -> <fileset>
  _create = base: tree: {
    _type = "fileset";
    # All attributes are internal
    _base = base;
    _tree = tree;
    # Double __ to make it be evaluated and ordered first
    __noEval = throw ''
      File sets are not intended to be directly inspected or evaluated. Instead prefer:
      - If you want to print a file set, use the `lib.fileset.trace` or `lib.fileset.pretty` function.
      - If you want to check file sets for equality, use the `lib.fileset.equals` function.
    '';
  };

  # Create a file set from a path
  # Type: Path -> <fileset>
  _singleton = path:
    let
      type = pathType path;
    in
    if type == "directory" then
      _create path type
    else
      # Always coerce to a directory
      # If we don't do this we run into problems like:
      # - What should `toSource { base = ./default.nix; fileset = difference ./default.nix ./default.nix; }` do?
      #   - Importing an empty directory wouldn't make much sense because our `base` is a file
      #   - Neither can we create a store path containing nothing at all
      #   - The only option is to throw an error that `base` should be a directory
      # - Should `fileFilter (file: file.name == "default.nix") ./default.nix` run the predicate on the ./default.nix file?
      #   - If no, should the result include or exclude ./default.nix? In any case, it would be confusing and inconsistent
      #   - If yes, it needs to consider ./. to have influence the filesystem result, because file names are part of the parent directory, so filter would change the necessary base
      _create (dirOf path)
        (_nestTree
          (dirOf path)
          [ (baseNameOf path) ]
          type
        );

  # Turn a builtins.filterSource-based source filter on a root path into a file set containing only files included by the filter
  # Type: Path -> (String -> String -> Bool) -> <fileset>
  _fromSource = root: filter:
    let
      recurse = focusPath: type:
        # FIXME: Generally we shouldn't use toString on paths, though it might be correct
        # here since we're trying to mimic the impure behavior of `builtins.filterPath`
        if ! filter (toString focusPath) type then
          null
        else if type == "directory" then
          mapAttrs
            (name: recurse (append focusPath name))
            (readDir focusPath)
        else
          type;


      rootPathType = pathType root;
      tree =
        if rootPathType == "directory" then
          recurse root rootPathType
        else
          rootPathType;
    in
    _create root tree;

  # Coerce a value to a fileset
  # Type: String -> String -> Any -> <fileset>
  _coerce = function: context: value:
    if value._type or "" == "fileset" then
      value
    else if ! isPath value then
      if value._isLibCleanSourceWith or false then
        throw ''
          lib.fileset.${function}: Expected ${context} to be a path, but it's a value produced by `lib.sources` instead.
              Such a value is only supported when converted to a file set using `lib.fileset.fromSource`.''
      else if isCoercibleToString value then
        throw ''
          lib.fileset.${function}: Expected ${context} to be a path, but it's a string-coercible value instead, possibly a Nix store path.
              Such a value is not supported, `lib.fileset` only supports local file filtering.''
      else
        throw "lib.fileset.${function}: Expected ${context} to be a path, but got a ${typeOf value}."
    else if ! pathExists value then
      throw "lib.fileset.${function}: Expected ${context} \"${toString value}\" to be a path that exists, but it doesn't."
    else
      _singleton value;

  # Nest a tree under some further components
  # Type: Path -> [ String ] -> <tree> -> <tree>
  _nestTree = targetBase: extraComponents: tree:
    let
      recurse = index: focusPath:
        if index == length extraComponents then
          tree
        else
          let
            focusedName = elemAt extraComponents index;
          in
          mapAttrs
            (name: _:
              if name == focusedName then
                recurse (index + 1) (append focusPath name)
              else
                null
            )
            (readDir focusPath);
    in
    recurse 0 targetBase;

  # Expand "directory" to { <name> = <tree>; }
  # Type: Path -> <directory> -> { <name> = <tree>; }
  _directoryEntries = path: value:
    if isAttrs value then
      value
    else
      readDir path;

  # The following tables are a bit complicated, but they nicely explain the
  # corresponding implementations, here's the legend:
  #
  # lhs\rhs: The values for the left hand side and right hand side arguments
  # null: null, an excluded file/directory
  # attrs: satisfies `isAttrs value`, an explicitly listed directory containing nested trees
  # dir: "directory", a recursively included directory
  # str: "regular", "symlink" or "unknown", a filetype string
  # rec: A result computed by recursing
  # -: Can't occur because one argument is a directory while the other is a file
  # <number>: Indicates that the result is computed by the branch with that number

  # The union of two <tree>'s
  # Type: <tree> -> <tree> -> <tree>
  #
  # lhs\rhs |   null  |   attrs |   dir |   str |
  # ------- | ------- | ------- | ----- | ----- |
  # null    | 2 null  | 2 attrs | 2 dir | 2 str |
  # attrs   | 3 attrs | 1 rec   | 2 dir |   -   |
  # dir     | 3 dir   | 3 dir   | 2 dir |   -   |
  # str     | 3 str   |   -     |   -   | 2 str |
  _unionTree = lhs: rhs:
    # Branch 1
    if isAttrs lhs && isAttrs rhs then
      mapAttrs (name: _unionTree lhs.${name}) rhs
    # Branch 2
    else if lhs == null || isString rhs then
      rhs
    # Branch 3
    else
      lhs;

  # The intersection of two <tree>'s
  # Type: <tree> -> <tree> -> <tree>
  #
  # lhs\rhs |   null  |   attrs |   dir   |   str  |
  # ------- | ------- | ------- | ------- | ------ |
  # null    | 2 null  | 2 null  | 2 null  | 2 null |
  # attrs   | 3 null  | 1 rec   | 2 attrs |   -    |
  # dir     | 3 null  | 3 attrs | 2 dir   |   -    |
  # str     | 3 null  |   -     |   -     | 2 str  |
  _intersectTree = lhs: rhs:
    # Branch 1
    if isAttrs lhs && isAttrs rhs then
      mapAttrs (name: _intersectTree lhs.${name}) rhs
    # Branch 2
    else if lhs == null || isString rhs then
      lhs
    # Branch 3
    else
      rhs;

  # The difference between two <tree>'s
  # Type: Path -> <tree> -> <tree> -> <tree>
  #
  # lhs\rhs |   null  |   attrs |   dir  |   str  |
  # ------- | ------- | ------- | ------ | ------ |
  # null    | 1 null  | 1 null  | 1 null | 1 null |
  # attrs   | 2 attrs | 3 rec   | 1 null |   -    |
  # dir     | 2 dir   | 3 rec   | 1 null |   -    |
  # str     | 2 str   |   -     |   -    | 1 null |
  _differenceTree = path: lhs: rhs:
    # Branch 1
    if isString rhs || lhs == null then
      null
    # Branch 2
    else if rhs == null then
      lhs
    # Branch 3
    else
      mapAttrs (name: lhsValue:
        _differenceTree (append path name) lhsValue rhs.${name}
      ) (_directoryEntries path lhs);

  # Whether two <tree>'s are equal
  # Type: Path -> <tree> -> <tree> -> <tree>
  #
  # | lhs\rhs |   null  |   attrs |   dir  |   str   |
  # | ------- | ------- | ------- | ------ | ------- |
  # | null    | 1 true  | 1 rec   | 1 rec  | 1 false |
  # | attrs   | 2 rec   | 3 rec   | 3 rec  |   -     |
  # | dir     | 2 rec   | 3 rec   | 4 true |   -     |
  # | str     | 2 false |   -     |   -    | 4 true  |
  _equalsTree = path: lhs: rhs:
    # Branch 1
    if lhs == null then
      _isEmptyTree path rhs
    # Branch 2
    else if rhs == null then
      _isEmptyTree path lhs
    # Branch 3
    else if isAttrs lhs || isAttrs rhs then
      let
        lhs' = _directoryEntries path lhs;
        rhs' = _directoryEntries path rhs;
      in
      all (name:
        _equalsTree (append path name) lhs'.${name} rhs'.${name}
      ) (attrNames lhs')
    # Branch 4
    else
      true;

  # Whether a tree is empty, containing no files
  # Type: Path -> <tree> -> Bool
  _isEmptyTree = path: tree:
    if isAttrs tree || tree == "directory" then
      let
        entries = _directoryEntries path tree;
      in
      all (name: _isEmptyTree (append path name) entries.${name}) (attrNames entries)
    else
      tree == null;

  # Simplifies a tree, optionally expanding all "directory"'s into complete listings
  # Type: Bool -> Path -> <tree> -> <tree>
  _simplifyTree = expand: base: tree:
    let
      recurse = focusPath: tree:
        if tree == "directory" && expand || isAttrs tree then
          let
            expanded = _directoryEntries focusPath tree;
            transformedSubtrees = mapAttrs (name: recurse (append focusPath name)) expanded;
            values = attrValues transformedSubtrees;
          in
          if all (value: value == "emptyDir") values then
            "emptyDir"
          else if all (value: isNull value || value == "emptyDir") values then
            null
          else if !expand && all (value: isString value || value == "emptyDir") values then
            "directory"
          else
            mapAttrs (name: value: if value == "emptyDir" then null else value) transformedSubtrees
        else
          tree;
      result = recurse base tree;
    in
    if result == "emptyDir" then
      null
    else
      result;

  _prettyTreeSuffix = tree:
    if isAttrs tree then
      ""
    else if tree == "directory" then
      " (recursive directory)"
    else
      " (${tree})";

  # Pretty-print all files included in the file set.
  # Type: (b -> String -> b) -> b -> Path -> FileSet -> b
  _prettyFoldl' = f: start: base: tree:
    let
      traceTreeAttrs = start: indent: tree:
        # Nix should really be evaluating foldl''s second argument before starting the iteration
        # See the same problem in Haskell:
        # - https://stackoverflow.com/a/14282642
        # - https://gitlab.haskell.org/ghc/ghc/-/issues/12173
        # - https://well-typed.com/blog/90/#a-postscript-which-foldl
        # - https://old.reddit.com/r/haskell/comments/21wvk7/foldl_is_broken/
        seq start
          (foldl' (prev: name:
            let
              subtree = tree.${name};

              intermediate =
                f prev "${indent}- ${name}${_prettyTreeSuffix subtree}";
            in
            if subtree == null then
              # Don't print anything at all if this subtree is empty
              prev
            else if isAttrs subtree then
              # A directory with explicit entries
              # Do print this node, but also recurse
              traceTreeAttrs intermediate "${indent}  " subtree
            else
              # Either a file, or a recursively included directory
              # Do print this node but no further recursion needed
              intermediate
          ) start (attrNames tree));

      intermediate =
        if tree == null then
          f start "${toString base} (empty)"
        else
          f start "${toString base}${_prettyTreeSuffix tree}";
    in
    if isAttrs tree then
      traceTreeAttrs intermediate "" tree
    else
      intermediate;

  # Coerce and normalise the bases of multiple file set values passed to user-facing functions
  # Type: String -> [ { context :: String, value :: Any } ] -> { commonBase :: Path, trees :: [ <tree> ] }
  _normaliseBase = function: list:
    let
      processed = map ({ context, value }:
        let
          fileset = _coerce function context value;
        in {
          inherit fileset context;
          baseParts = deconstruct fileset._base;
        }
      ) list;

      first = head processed;

      commonComponents = foldl' (components: el:
        if first.baseParts.root != el.baseParts.root then
          throw "lib.fileset.${function}: Expected file sets to have the same filesystem root, but ${first.context} has root \"${toString first.baseParts.root}\" while ${el.context} has root \"${toString el.baseParts.root}\"."
        else
          commonPrefix components el.baseParts.components
      ) first.baseParts.components (tail processed);

      commonBase = construct {
        root = first.baseParts.root;
        components = commonComponents;
      };

      commonComponentsLength = length commonComponents;

      trees = map (value:
        _nestTree
          commonBase
          (drop commonComponentsLength value.baseParts.components)
          value.fileset._tree
      ) processed;
    in
    {
      inherit commonBase trees;
    };

in {

  /*
  Import a file set into the Nix store, making it usable inside derivations.
  Return a source-like value that can be coerced to a Nix store path.

  This function takes an attribute set with these attributes as an argument:

  - `root` (required): The local path that should be the root of the result.
    `fileset` must not be influenceable by paths outside `root`, meaning `lib.fileset.getInfluenceBase fileset` must be under `root`.

    Warning: Setting `root` to `lib.fileset.getInfluenceBase fileset` directly would make the resulting Nix store path file structure dependent on how `fileset` is declared.
    This makes it non-trivial to predict where specific paths are located in the result.

  - `fileset` (required): The set of files to import into the Nix store.
    Use the other `lib.fileset` functions to define `fileset`.
    Only directories containing at least one file are included in the result, unless `extraExistingDirs` is used to ensure the existence of specific directories even without any files.

  - `extraExistingDirs` (optional, default `[]`): Additionally ensure the existence of these directory paths in the result, even they don't contain any files in `fileset`.

  Type:
    toSource :: {
      root :: Path,
      fileset :: FileSet,
      extraExistingDirs :: [ Path ] ? [ ],
    } -> SourceLike
  */
  toSource = { root, fileset, extraExistingDirs ? [ ] }:
    let
      maybeFileset = fileset;
    in
    let
      fileset = _coerce "toSource" "`fileset` attribute" maybeFileset;

      # Directories that recursively have no files in them will always be `null`
      sparseTree =
        let
          recurse = focusPath: tree:
            if tree == "directory" || isAttrs tree then
              let
                entries = _directoryEntries focusPath tree;
                sparseSubtrees = mapAttrs (name: recurse (append focusPath name)) entries;
                values = attrValues sparseSubtrees;
              in
              if all isNull values then
                null
              else if all isString values then
                "directory"
              else
                sparseSubtrees
            else
              tree;
          resultingTree = recurse fileset._base fileset._tree;
          # The fileset's _base might be below the root of the `toSource`, so we need to lift the tree up to `root`
          extraRootNesting = removePrefix root fileset._base;
        in _nestTree root extraRootNesting resultingTree;

      sparseExtendedTree =
        if ! isList extraExistingDirs then
          throw "lib.fileset.toSource: Expected the `extraExistingDirs` attribute to be a list, but it's a ${typeOf extraExistingDirs} instead."
        else
          lib.foldl' (tree: i:
            let
              dir = elemAt extraExistingDirs i;

              # We're slightly abusing the internal functions and structure to ensure that the extra directory is represented in the sparse tree.
              value = mapAttrs (name: value: null) (readDir dir);
              extraTree = _nestTree root (removePrefix root dir) value;
              result = _unionTree tree extraTree;
            in
            if ! isPath dir then
              throw "lib.fileset.toSource: Expected all elements of the `extraExistingDirs` attribute to be paths, but element at index ${toString i} is a ${typeOf dir} instead."
            else if ! pathExists dir then
              throw "lib.fileset.toSource: Expected all elements of the `extraExistingDirs` attribute to be paths that exist, but the path at index ${toString i} \"${toString dir}\" does not."
            else if pathType dir != "directory" then
              throw "lib.fileset.toSource: Expected all elements of the `extraExistingDirs` attribute to be paths pointing to directories, but the path at index ${toString i} \"${toString dir}\" points to a file instead."
            else if ! hasPrefix root dir then
              throw "lib.fileset.toSource: Expected all elements of the `extraExistingDirs` attribute to be paths under the `root` attribute \"${toString root}\", but the path at index ${toString i} \"${toString dir}\" is not."
            else
              result
          ) sparseTree (range 0 (length extraExistingDirs - 1));

      rootComponentsLength = length (deconstruct root).components;

      # This function is called often for the filter, so it should be fast
      inSet = components:
        let
          recurse = index: localTree:
            if index == length components then
              localTree != null
            else if localTree ? ${elemAt components index} then
              recurse (index + 1) localTree.${elemAt components index}
            else
              localTree == "directory";
        in recurse rootComponentsLength sparseExtendedTree;

    in
    if ! isPath root then
      if root._isLibCleanSourceWith or false then
        throw ''
          lib.fileset.toSource: Expected attribute `root` to be a path, but it's a value produced by `lib.sources` instead.
              Such a value is only supported when converted to a file set using `lib.fileset.fromSource` and passed to the `fileset` attribute, where it may also be combined using other functions from `lib.fileset`.''
      else if isCoercibleToString root then
        throw ''
          lib.fileset.toSource: Expected attribute `root` to be a path, but it's a string-like value instead, possibly a Nix store path.
              Such a value is not supported, `lib.fileset` only supports local file filtering.''
      else
        throw "lib.fileset.toSource: Expected attribute `root` to be a path, but it's a ${typeOf root} instead."
    else if ! pathExists root then
      throw "lib.fileset.toSource: Expected attribute `root` \"${toString root}\" to be a path that exists, but it doesn't."
    else if pathType root != "directory" then
      throw "lib.fileset.toSource: Expected attribute `root` \"${toString root}\" to be a path pointing to a directory, but it's pointing to a file instead."
    else if ! hasPrefix root fileset._base then
      throw "lib.fileset.toSource: Expected attribute `fileset` to not be influenceable by any paths outside `root`, but `lib.fileset.getInfluenceBase fileset` \"${toString fileset._base}\" is outside `root`."
    else
      cleanSourceWith {
        name = "source";
        src = root;
        filter = pathString: _: inSet (fromSubpath "./${pathString}");
      };

  /*
  Create a file set from a filtered local source as produced by the `lib.sources` functions.
  This does not import anything into the store.

  Type:
    fromSource :: SourceLike -> FileSet

  Example:
    fromSource (lib.sources.cleanSource ./.)
  */
  fromSource = source:
    if ! source._isLibCleanSourceWith or false || ! source ? origSrc || ! source ? filter then
      throw "lib.fileset.fromSource: Expected the argument to be a value produced from `lib.sources`, but got a ${typeOf source} instead."
    else if ! isPath source.origSrc then
      throw "lib.fileset.fromSource: Expected the argument to be source-like value of a local path."
    else
      _fromSource source.origSrc source.filter;

  /*
  Coerce a value to a file set:

  - If the value is a file set already, return it directly

  - If the value is a path pointing to a file, return a file set with that single file

  - If the value is a path pointing to a directory, return a file set with all files contained in that directory

  This function is mostly not needed because all functions in `lib.fileset` will implicitly apply it for arguments that are expected to be a file set.

  Type:
    coerce :: Any -> FileSet
  */
  coerce = value: _coerce "coerce" "argument" value;


  /*
  Create a file set containing all files contained in a path (see `coerce`), or no files if the path doesn't exist.

  This is useful when you want to include a file only if it actually exists.

  Type:
    optional :: Path -> FileSet
  */
  optional = path:
    if ! isPath path then
      throw "lib.fileset.optional: Expected argument to be a path, but got a ${typeOf path}."
    else if pathExists path then
      _singleton path
    else
      _create path null;

  /*
  Return the common ancestor directory of all file set operations used to construct this file set, meaning that nothing outside the this directory can influence the set of files included.

  Type:
    getInfluenceBase :: FileSet -> Path

  Example:
    getInfluenceBase ./Makefile
    => ./.

    getInfluenceBase ./src
    => ./src

    getInfluenceBase (fileFilter (file: false) ./.)
    => ./.

    getInfluenceBase (union ./Makefile ../default.nix)
    => ../.
  */
  getInfluenceBase = maybeFileset:
    let
      fileset = _coerce "getInfluenceBase" "argument" maybeFileset;
    in
    fileset._base;

  /*
  Incrementally evaluate and trace a file set in a pretty way.
  Functionally this is the same as splitting the result from `lib.fileset.pretty` into lines and tracing those.
  However this function can do the same thing incrementally, so it can already start printing the result as the first lines are known.

  The `expand` argument (false by default) controls whether all files should be printed individually.

  Type:
    trace :: { expand :: Bool ? false } -> FileSet -> Any -> Any

  Example:
    trace {} (unions [ ./foo.nix ./bar/baz.c ./qux ])
    =>
    trace: /home/user/src/myProject
    trace: - bar
    trace:   - baz.c (regular)
    trace: - foo.nix (regular)
    trace: - qux (recursive directory)
    null

    trace { expand = true; } (unions [ ./foo.nix ./bar/baz.c ./qux ])
    =>
    trace: /home/user/src/myProject
    trace: - bar
    trace:   - baz.c (regular)
    trace: - foo.nix (regular)
    trace: - qux
    trace:   - florp.c (regular)
    trace:   - florp.h (regular)
    null
  */
  trace = { expand ? false }: maybeFileset:
    let
      fileset = _coerce "trace" "second argument" maybeFileset;
      simpleTree = _simplifyTree expand fileset._base fileset._tree;
    in
    _prettyFoldl' (acc: el: builtins.trace el acc) (x: x)
      fileset._base
      simpleTree;

  /*
  The same as `lib.fileset.trace`, but instead of taking an argument for the value to return, the given file set is returned instead.

  Type:
    traceVal :: { expand :: Bool ? false } -> FileSet -> FileSet
  */
  traceVal = { expand ? false }: maybeFileset:
    let
      fileset = _coerce "traceVal" "second argument" maybeFileset;
      simpleTree = _simplifyTree expand fileset._base fileset._tree;
    in
    _prettyFoldl' (acc: el: builtins.trace el acc) fileset
      fileset._base
      simpleTree;

  /*
  The same as `lib.fileset.trace`, but instead of tracing each line, the result is returned as a string.

  Type:
    pretty :: { expand :: Bool ? false } -> FileSet -> String
  */
  pretty = { expand ? false }: maybeFileset:
    let
      fileset = _coerce "pretty" "second argument" maybeFileset;
      simpleTree = _simplifyTree expand fileset._base fileset._tree;
    in
    _prettyFoldl' (acc: el: "${acc}\n${el}") ""
      fileset._base
      simpleTree;

  /*
  The file set containing all files that are in either of two given file sets.
  Recursively, the first argument is evaluated first, only evaluating the second argument if necessary.

      union a b = a ⋃ b

  Type:
    union :: FileSet -> FileSet -> FileSet
  */
  union = lhs: rhs:
    let
      normalised = _normaliseBase "union" [
        {
          context = "first argument";
          value = lhs;
        }
        {
          context = "second argument";
          value = rhs;
        }
      ];
    in
    _create normalised.commonBase
      (_unionTree
        (elemAt normalised.trees 0)
        (elemAt normalised.trees 1)
      );

  /*
  The file containing all files from that are in any of the given file sets.
  Recursively, the elements are evaluated from left to right, only evaluating arguments on the right if necessary.

  Type:
    unions :: [FileSet] -> FileSet
  */
  unions = list:
    let
      annotated = imap0 (i: el: {
        context = "element ${toString i} of the argument";
        value = el;
      }) list;

      normalised = _normaliseBase "unions" annotated;

      tree = foldl' _unionTree (head normalised.trees) (tail normalised.trees);
    in
    if ! isList list then
      throw "lib.fileset.unions: Expected argument to be a list, but got a ${typeOf list}."
    else if length list == 0 then
      throw "lib.fileset.unions: Expected argument to be a list with at least one element, but it contains no elements."
    else
      _create normalised.commonBase tree;

  /*
  The file set containing all files that are in both given sets.
  Recursively, the first argument is evaluated first, only evaluating the second argument if necessary.

      intersect a b == a ⋂ b

  Type:
    intersect :: FileSet -> FileSet -> FileSet
  */
  intersect = lhs: rhs:
    let
      normalised = _normaliseBase "intersect" [
        {
          context = "first argument";
          value = lhs;
        }
        {
          context = "second argument";
          value = rhs;
        }
      ];
    in
    _create normalised.commonBase
      (_intersectTree
        (elemAt normalised.trees 0)
        (elemAt normalised.trees 1)
      );

  /*
  The file set containing all files that are in all the given sets.
  Recursively, the elements are evaluated from left to right, only evaluating arguments on the right if necessary.

  Type:
    intersects :: [FileSet] -> FileSet
  */
  intersects = list:
    let
      annotated = imap0 (i: el: {
        context = "element ${toString i} of the argument";
        value = el;
      }) list;

      normalised = _normaliseBase "intersects" annotated;

      tree = foldl' _intersectTree (head normalised.trees) (tail normalised.trees);
    in
    if ! isList list then
      throw "lib.fileset.intersects: Expected argument to be a list, but got a ${typeOf list}."
    else if length list == 0 then
      throw "lib.fileset.intersects: Expected argument to be a list with at least one element, but it contains no elements."
    else
      _create normalised.commonBase tree;

  /*
  The file set containing all files that are in the first file set but not in the second.
  Recursively, the second argument is evaluated first, only evaluating the first argument if necessary.

      difference a b == a ∖ b

  Type:
    difference :: FileSet -> FileSet -> FileSet
  */
  difference = lhs: rhs:
    let
      normalised = _normaliseBase "difference" [
        {
          context = "first argument";
          value = lhs;
        }
        {
          context = "second argument";
          value = rhs;
        }
      ];
    in
    _create normalised.commonBase
      (_differenceTree normalised.commonBase
        (elemAt normalised.trees 0)
        (elemAt normalised.trees 1)
      );

  /*
  Filter a file set to only contain files matching some predicate.

  The predicate is called with an attribute set containing these attributes:

  - `name`: The filename

  - `type`: The type of the file, either "regular", "symlink" or "unknown"

  - `ext`: The file extension or `null` if the file has none.

    More formally:
    - `ext` contains no `.`
    - `.${ext}` is a suffix of the `name`

  - Potentially other attributes in the future

  Type:
    fileFilter ::
      ({
        name :: String,
        type :: String,
        ext :: String | Null,
        ...
      } -> Bool)
      -> FileSet
      -> FileSet
  */
  fileFilter = predicate: maybeFileset:
    let
      fileset = _coerce "fileFilter" "second argument" maybeFileset;
      recurse = focusPath: tree:
        mapAttrs (name: subtree:
          if isAttrs subtree || subtree == "directory" then
            recurse (append focusPath name) subtree
          else if
            predicate {
              inherit name;
              type = subtree;
              ext = mapNullable head (match ".*\\.(.*)" name);
              # To ensure forwards compatibility with more arguments being added in the future,
              # adding an attribute which can't be deconstructed :)
              "This attribute is passed to prevent `lib.fileset.fileFilter` predicate functions from breaking when more attributes are added in the future. Please add `...` to the function to handle this and future additional arguments." = null;
            }
          then
            subtree
          else
            null
        ) (_directoryEntries focusPath tree);
    in
    _create fileset._base (recurse fileset._base fileset._tree);

  /*
  A file set containing all files that are contained in a directory whose name satisfies the given predicate.
  Only directories under the given path are checked, this is to ensure that components outside of the given path cannot influence the result.
  Consequently this function does not accept a file set as an argument.
  If you need to filter files in a file set based on components, use `intersect myFileSet (directoryFilter myPredicate myPath)` instead.

  Type:
    directoryFilter :: (String -> Bool) -> Path -> FileSet

  Example:
    # Select all files in hidden directories within ./.
    directoryFilter (hasPrefix ".") ./.

    # Select all files in directories named `build` within ./src
    directoryFilter (name: name == "build") ./src
  */
  directoryFilter = predicate: path:
    let
      recurse = focusPath:
        mapAttrs (name: type:
          if type == "directory" then
            if predicate name then
              type
            else
              recurse (append focusPath name)
          else
            null
        ) (readDir focusPath);
    in
    if path._type or null == "fileset" then
      throw ''
        lib.fileset.directoryFilter: Expected second argument to be a path, but it's a file set.
            If you need to filter files in a file set, use `intersect myFileSet (directoryFilter myPredicate myPath)` instead.''
    else if ! isPath path then
      throw "lib.fileset.directoryFilter: Expected second argument to be a path, but got a ${typeOf path}."
    else if pathType path != "directory" then
      throw "lib.fileset.directoryFilter: Expected second argument \"${toString path}\" to be a directory, but it's not."
    else
      _create path (recurse path);

  /*
  Check whether two file sets contain the same files.

  Type:
    equals :: FileSet -> FileSet -> Bool
  */
  equals = lhs: rhs:
    let
      normalised = _normaliseBase "equals" [
        {
          context = "first argument";
          value = lhs;
        }
        {
          context = "second argument";
          value = rhs;
        }
      ];
    in
    _equalsTree normalised.commonBase
      (elemAt normalised.trees 0)
      (elemAt normalised.trees 1);

  /*
  Check whether a file set contains no files.

  Type:
    isEmpty :: FileSet -> Bool
  */
  isEmpty = maybeFileset:
    let
      fileset = _coerce "isEmpty" "argument" maybeFileset;
    in
    _isEmptyTree fileset._base fileset._tree;
}