diff --git a/default.nix b/default.nix index 35a779a..452a454 100644 --- a/default.nix +++ b/default.nix @@ -3,40 +3,133 @@ # if evaluating inside the store, import the outputs.nix file let - # Ideally this file should not depend on nixpkgs lib itself, but I like the utilities here + # Ideally this file should be selfcontained, but I like the utilities in nixpkgs lib lib = (import "${(import ./inputs.nix {}).nixpkgs}/lib"); - gitignore = builtins.filter (v: - # ignore comments and empty lines - if !(builtins.isString v) then false - else if !builtins.isNull(builtins.match "^#.*" v) then false - else if !builtins.isNull(builtins.match "^$" v) then false - else true - ) (builtins.split "\n" (builtins.readFile ./.gitignore)); - - # checks if a given path matches a gitignore pattern - # string -> bool - matchesGitIgnore = path: builtins.any (pattern: - let - patternLength = builtins.stringLength pattern; - unsupportedPatternMessage = "matchesGitIgnore: Unsupported pattern: ${pattern}"; - in - if pattern == "*" then true - else if pattern == ".*" then true - else if pattern == "*.*" then true - else if builtins.substring 0 2 pattern == "*." then lib.hasSuffix (builtins.substring 0 2 pattern) path - else if lib.hasInfix "*" pattern then abort unsupportedPatternMessage - else if patternLength > 2 && builtins.substring 0 2 pattern == "./" then abort unsupportedPatternMessage - else if patternLength > 1 && builtins.substring 0 1 pattern == "/" then abort unsupportedPatternMessage - else lib.hasInfix pattern path - ) gitignore; + # function that takes gitignore file pattern and returns filter function + # true - include file + # false - exclude file + # null - no match + # string -> string -> [(string -> string -> (bool | null))] + toGitignoreMatcher = gitignorePath: pattern: lib.pipe pattern [ + (v: { pattern = v; invalid = false; }) + # trim whitespaces not preceded by backslash + (v: v // { pattern = let + stringLength = builtins.stringLength v.pattern; + leftPaddingLength = builtins.stringLength (lib.trimWith { start = true; end = false; } v.pattern) - stringLength; + rightPaddingLength = builtins.stringLength (lib.trimWith { start = false; end = true; } v.pattern) - stringLength; + isLastCharBackslash = if stringLength == 0 then false + else builtins.substring (stringLength - rightPaddingLength - 1) 1 v.pattern == "\\"; + trimmedString = builtins.substring leftPaddingLength (stringLength - leftPaddingLength - rightPaddingLength) v.pattern; + in if isLastCharBackslash && rightPaddingLength > 0 then trimmedString + " " else trimmedString; }) + # ignore empty lines + (v: if v.pattern != "" then v else v // { invalid = true; }) + # ignore comments + (v: if !v.invalid && builtins.substring 0 1 v.pattern != "#" then v else v // { invalid = true; }) + # mark negated patterns + (v: + if !v.invalid && builtins.substring 0 1 v.pattern == "!" + then v // { + negated = true; + pattern = builtins.substring 1 (builtins.stringLength v) v; + } + else v // { negated = false; } + ) + # ignore escapes + (v: if v.invalid then v else v // { pattern = builtins.replaceStrings ["\\"] [""] v.pattern; }) + # convert parsed pattern to matchers + ({ pattern, negated, invalid }: { + __functor = _: path: type: let + relative = builtins.match "^/.+[^/]$" pattern == []; + directory = builtins.match "/$" pattern == []; + regexPattern = lib.pipe pattern [ + (v: if relative then "${gitignorePath}/${v}" else v) + (builtins.split "/") + (builtins.filter (v: v != [])) + (builtins.map (builtins.split "(\\*\\*|\\*)")) + (builtins.concatMap (v: + # v: (string | [string])[] + if v == [ "" ] then [] + # TODO: check and add support for .. if git supports + else if v == [ "." ] then [] + else [( builtins.foldl' (acc: vp: + # vp: string | [string] + if builtins.isString vp then acc + lib.escapeRegex vp + else if vp == [ "**" ] then acc + ".*" + else if vp == [ "*" ] then acc + "[^/]*" + else throw "unreachable" + ) "" v )] + )) + (builtins.concatStringsSep "/" ) + (v: if relative then v else ".*/${v}") + ]; + matches = (!directory || type == "directory") + && (builtins.match regexPattern path == []); + in if invalid then null + else if matches then negated + else null; + # for debug purposes + inherit pattern negated; + # for filtering purposes + inherit invalid; + }) + ]; + + # TODO: optimize this so if match is found in a given gitignore, + # no further checks in gitignores in parent directories are performed + + parseGitignore = gitRepositoryPath: filePath: lib.pipe filePath [ + (builtins.dirOf) + (builtins.split "/" ) + (builtins.filter (v: v != [] && v != "")) + # ["a" "b" "c"] -> ["/" "/a/" "/a/b/" "/a/b/c/"] + ( + builtins.foldl' (acc: v: acc ++ [( + (builtins.elemAt acc (builtins.length acc - 1)) + "${v}/" + )] ) ["/"] + ) + (builtins.map (v: "${v}.gitignore")) + # Filter out paths that are not part of git repository and don't exist + (builtins.filter (v: lib.hasPrefix gitRepositoryPath v && builtins.pathExists v)) + (builtins.map (v: { + path = v; + # Split gitignore files into lines + contents = lib.pipe v [ + builtins.readFile + (builtins.split "\n") + # builtins.split uses lists for matches + (builtins.filter (v: v != [])) + ]; + })) + # Convert gitignore patterns to matchers + (builtins.map (v: + builtins.map (toGitignoreMatcher v.path) v.contents) + ) + lib.flatten + (lib.filter (v: !v.invalid)) + ]; + + runGitignoreFilter = filters: path: type: lib.pipe filters [ + (builtins.map (v: v path type)) + (builtins.filter (v: v != null)) + # If any filter didn't match anything, include the file + (v: if v == [] then [ true ] else v) + (v: builtins.elemAt v (builtins.length v - 1)) + ]; currentFilePath = (builtins.unsafeGetAttrPos "any" { any = "any"; }).file; storePathLength = builtins.stringLength (builtins.toString builtins.storeDir); evaluatingInStore = (builtins.substring 0 storePathLength currentFilePath) == builtins.storeDir; selfInStore = builtins.filterSource (path: type: - type != "unknown" && builtins.baseNameOf path != ".git" && !matchesGitIgnore path + let + selfPath = builtins.dirOf currentFilePath; + gitIgnoreFilters = parseGitignore selfPath path; + result = type != "unknown" + && type != "symlink" + && builtins.baseNameOf path != ".git" + && runGitignoreFilter gitIgnoreFilters path type; + in result ) ./.; in if !(evaluatingInStore) then { ... }@args: import selfInStore ({ selfPath = selfInStore; } // args )