├── tests ├── fixture1 │ ├── README.md │ ├── package.json │ └── src │ │ ├── main.js │ │ ├── innerdir │ │ └── inner.js │ │ └── components │ │ └── widget.jsx ├── default.nix ├── run-tests.sh └── tests.nix ├── .github ├── dependabot.yml └── workflows │ └── nix.yml ├── flake.nix ├── LICENSE ├── default.nix ├── nix-filter.svg └── README.md /tests/fixture1/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixture1/package.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixture1/src/main.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixture1/src/innerdir/inner.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixture1/src/components/widget.jsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "nix-filter"; 3 | 4 | outputs = { self }: { 5 | __functor = self.lib.__functor; 6 | lib = import ./default.nix; 7 | overlays.default = _: _: { nix-filter = self.lib; }; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths-ignore: 6 | - '**.md' 7 | pull_request: 8 | paths-ignore: 9 | - '**.md' 10 | workflow_dispatch: 11 | jobs: 12 | build: 13 | strategy: 14 | matrix: 15 | os: 16 | - macos-latest 17 | - ubuntu-20.04 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: cachix/install-nix-action@v26 22 | - run: tests/run-tests.sh 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Numtide 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/default.nix: -------------------------------------------------------------------------------- 1 | let 2 | testCases = import ./tests.nix; 3 | 4 | # Convert a path to a string with the root stripped 5 | toRelativeString = root: path: 6 | let 7 | r = toString root; 8 | p = toString path; 9 | in 10 | builtins.substring 11 | (builtins.stringLength r + 1) 12 | (builtins.stringLength p) 13 | p; 14 | 15 | # Traverse a directory, returning all children in a list 16 | listDir = 17 | root: 18 | let 19 | files = builtins.readDir root; 20 | filesAsList = map 21 | (fileName: 22 | { 23 | path = root + ("/" + fileName); 24 | type = builtins.getAttr fileName files; 25 | } 26 | ) 27 | (builtins.attrNames files); 28 | pathsInDir = map (file: file.path) filesAsList; 29 | nestedPaths = builtins.concatMap (file: listDir file.path) 30 | (builtins.filter (file: file.type == "directory") filesAsList); 31 | in 32 | pathsInDir ++ nestedPaths; 33 | 34 | # Run a test, returning a list of failures 35 | runTest = testDef: 36 | let 37 | missing = builtins.filter 38 | (file: 39 | ! builtins.pathExists (testDef.actual + ("/" + file)) 40 | ) 41 | testDef.expected; 42 | included = map (toRelativeString testDef.actual) (listDir testDef.actual); 43 | extra = builtins.filter 44 | (path: 45 | ! builtins.elem path testDef.expected 46 | ) 47 | included; 48 | in 49 | (map (x: { path = x; status = "missing"; }) missing) 50 | ++ 51 | (map (x: { path = x; status = "extra"; }) extra); 52 | 53 | # Take a set of test results and filter out every key that 54 | # is not failing. 55 | onlyFailures = results: 56 | let 57 | names = builtins.attrNames results; 58 | in 59 | builtins.foldl' 60 | (finalFailures: testName: 61 | let 62 | failures = builtins.getAttr testName results; 63 | in 64 | if builtins.length failures == 0 65 | then finalFailures 66 | else finalFailures // { ${testName} = failures; } 67 | ) 68 | { } 69 | names; 70 | 71 | testResults = builtins.mapAttrs (_: testDef: runTest testDef) testCases; 72 | in 73 | testResults // { "@onlyFailures" = onlyFailures testResults; } 74 | -------------------------------------------------------------------------------- /tests/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Our little test runner. 4 | 5 | set -euo pipefail 6 | 7 | cd "$(dirname "$0")" 8 | 9 | nix_args=( 10 | # Simulate flakes. Paths that are in the include are allowed. 11 | --include "prj_root=.." 12 | --option restrict-eval true 13 | --option allow-import-from-derivation false 14 | ) 15 | 16 | if [[ "$#" -eq 1 ]]; then 17 | nix_args+=(-A "$1") 18 | fi 19 | 20 | # Need to build first or the store paths don't exist 21 | # for default.nix to traverse 22 | nix-build "${nix_args[@]}" >/dev/null 23 | echo "---------------------------------------------------------------------" 24 | results="$(nix-instantiate --eval --strict --json "${nix_args[@]}")" 25 | 26 | # Normalize input before handing it over to jq 27 | if [[ "$#" -eq 1 && "${1::1}" != "@" ]]; then 28 | results="{ \"$1\": $results }" 29 | fi 30 | 31 | # Parse and format the results with jq 32 | # 33 | # This expects the JSON format to look like: 34 | # { "test-case-name": [ { "path": "./path/to/file", "status": "missing" } ] } 35 | # 36 | # There is a special case ("@onlyFailures") which is a key with 37 | # the expected format nested inside of it. In order to not choke 38 | # on this "report" style of key, we filter out keys starting with 39 | # "@". 40 | result_string=$( 41 | echo "$results" |\ 42 | jq -r ' 43 | "\\e[0;91m" as $red | 44 | "\\e[0;92m" as $green | 45 | "\\e[0m" as $reset | 46 | to_entries | 47 | map(select(.key|startswith("@") == false)) | 48 | ( 49 | map( 50 | (.value|length) as $errors | 51 | "TEST: " + .key + " " + 52 | ( 53 | if $errors > 0 54 | then $red + "(" + ($errors|tostring) + " errors" 55 | else $green + "(SUCCESS" end 56 | ) + 57 | ")" + $reset + "\n" + 58 | ( .value | 59 | map( 60 | " " + 61 | ( 62 | if .status == "missing" 63 | then "MISSING " 64 | else "EXTRA " end 65 | ) + 66 | .path + "\n" 67 | ) | add 68 | ) 69 | )|.[] 70 | ), 71 | ( 72 | [ (map(select(.value|length > 0))|length) 73 | , (map(select(.value|length == 0))|length) 74 | ] | 75 | "Tests completed. " + 76 | ( 77 | if .[1] > 0 78 | then $green + (.[1]|tostring) + " succeeded" + $reset + ". " 79 | else "" end 80 | ) + 81 | ( 82 | if .[0] > 0 83 | then $red + (.[0]|tostring) + " failed" + $reset + "." 84 | else "" end 85 | ) 86 | )' 87 | ) 88 | 89 | echo -e "$result_string" 90 | 91 | # If there are errors in the output, return a non-zero exit code 92 | if grep -Po "^TEST:.*?\(\d+ errors\)" <<< "$result_string" &>/dev/null; then 93 | exit 1 94 | fi 95 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | # This is a pure and self-contained library 2 | rec { 3 | # Default to filter when calling this lib. 4 | __functor = self: filter; 5 | 6 | # A proper source filter 7 | filter = 8 | { 9 | # Base path to include 10 | root 11 | , # Derivation name 12 | name ? "source" 13 | , # Only include the following path matches. 14 | # 15 | # Allows all files by default. 16 | include ? [ (_:_:_: true) ] 17 | , # Ignore the following matches 18 | exclude ? [ ] 19 | }: 20 | assert _pathIsDirectory root; 21 | let 22 | callMatcher = args: _toMatcher ({ inherit root; } // args); 23 | include_ = map (callMatcher { matchParents = true; }) include; 24 | exclude_ = map (callMatcher { matchParents = false; }) exclude; 25 | in 26 | builtins.path { 27 | inherit name; 28 | path = root; 29 | filter = path: type: 30 | (builtins.any (f: f path type) include_) && 31 | (!builtins.any (f: f path type) exclude_); 32 | }; 33 | 34 | # Match a directory and any path inside of it 35 | inDirectory = 36 | directory: 37 | args: 38 | let 39 | # Convert `directory` to a path to clean user input. 40 | directory_ = _toCleanPath args.root directory; 41 | in 42 | path: type: 43 | directory_ == path 44 | # Add / to the end to make sure we match a full directory prefix 45 | || _hasPrefix (directory_ + "/") path; 46 | 47 | # Match any directory 48 | isDirectory = _: _: type: type == "directory"; 49 | 50 | # Combines matchers 51 | and = a: b: args: 52 | let 53 | toMatcher = _toMatcher args; 54 | in 55 | path: type: 56 | (toMatcher a path type) && (toMatcher b path type); 57 | 58 | # Combines matchers 59 | or_ = a: b: args: 60 | let 61 | toMatcher = _toMatcher args; 62 | in 63 | path: type: 64 | (toMatcher a path type) || (toMatcher b path type); 65 | 66 | # Or is actually a keyword, but can also be used as a key in an attrset. 67 | or = or_; 68 | 69 | # Match paths with the given extension 70 | matchExt = ext: 71 | args: path: type: 72 | _hasSuffix ".${ext}" path; 73 | 74 | # Filter out files or folders with this exact name 75 | matchName = name: 76 | root: path: type: 77 | builtins.baseNameOf path == name; 78 | 79 | # Wrap a matcher with this to debug its results 80 | debugMatch = label: fn: 81 | args: path: type: 82 | let 83 | ret = fn args path type; 84 | retStr = if ret then "true" else "false"; 85 | in 86 | builtins.trace "label=${label} path=${path} type=${type} ret=${retStr}" 87 | ret; 88 | 89 | # Add this at the end of the include or exclude, to trace all the unmatched paths 90 | traceUnmatched = args: path: type: 91 | builtins.trace "unmatched path=${path} type=${type}" false; 92 | 93 | # Lib stuff 94 | 95 | # If an argument to include or exclude is a path, transform it to a matcher. 96 | # 97 | # This probably needs more work, I don't think that it works on 98 | # sub-folders. 99 | _toMatcher = args: f: 100 | let 101 | path_ = _toCleanPath args.root f; 102 | pathIsDirectory = _pathIsDirectory path_; 103 | in 104 | if builtins.isFunction f then f args 105 | else path: type: 106 | (if pathIsDirectory then 107 | inDirectory path_ args path type 108 | else 109 | path_ == path) || args.matchParents 110 | && type == "directory" 111 | && _hasPrefix "${path}/" path_; 112 | 113 | 114 | # Makes sure a path is: 115 | # * absolute 116 | # * doesn't contain superfluous slashes or .. 117 | # 118 | # Returns a string so there is no risk of adding it to the store by mistake. 119 | _toCleanPath = absPath: path: 120 | assert _pathIsDirectory absPath; 121 | if builtins.isPath path then 122 | toString path 123 | else if builtins.isString path then 124 | if builtins.substring 0 1 path == "/" then 125 | path 126 | else 127 | toString (absPath + ("/" + path)) 128 | else 129 | throw "unsupported type ${builtins.typeOf path}, expected string or path"; 130 | 131 | _hasSuffix = 132 | # Suffix to check for 133 | suffix: 134 | # Input string 135 | content: 136 | let 137 | lenContent = builtins.stringLength content; 138 | lenSuffix = builtins.stringLength suffix; 139 | in 140 | lenContent >= lenSuffix 141 | && builtins.substring (lenContent - lenSuffix) lenContent content == suffix; 142 | 143 | _hasPrefix = 144 | # Prefix to check for 145 | prefix: 146 | # Input string 147 | content: 148 | let 149 | lenPrefix = builtins.stringLength prefix; 150 | in 151 | prefix == builtins.substring 0 lenPrefix content; 152 | 153 | # Returns true if the path exists and is a directory and false otherwise 154 | _pathIsDirectory = p: 155 | let 156 | parent = builtins.dirOf p; 157 | base = builtins.unsafeDiscardStringContext (builtins.baseNameOf p); 158 | inNixStore = builtins.storeDir == toString parent; 159 | in 160 | # If the parent folder is /nix/store, we assume p is a directory. Because 161 | # reading /nix/store is very slow, and not allowed in every environments. 162 | inNixStore || 163 | ( 164 | builtins.pathExists p && 165 | (builtins.readDir parent).${builtins.unsafeDiscardStringContext base} == "directory" 166 | ); 167 | 168 | } 169 | -------------------------------------------------------------------------------- /nix-filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/tests.nix: -------------------------------------------------------------------------------- 1 | let 2 | nix-filter = import ../.; 3 | in 4 | { 5 | all = rec { 6 | root = ./fixture1; 7 | actual = nix-filter { 8 | inherit root; 9 | }; 10 | expected = [ 11 | "README.md" 12 | "package.json" 13 | "src" 14 | "src/main.js" 15 | "src/components" 16 | "src/components/widget.jsx" 17 | "src/innerdir" 18 | "src/innerdir/inner.js" 19 | ]; 20 | }; 21 | 22 | without-readme = rec { 23 | root = ./fixture1; 24 | actual = nix-filter { 25 | inherit root; 26 | exclude = [ 27 | "README.md" 28 | ]; 29 | }; 30 | expected = [ 31 | "package.json" 32 | "src" 33 | "src/main.js" 34 | "src/components" 35 | "src/components/widget.jsx" 36 | "src/innerdir" 37 | "src/innerdir/inner.js" 38 | ]; 39 | }; 40 | 41 | with-matchExt = rec { 42 | root = ./fixture1; 43 | actual = nix-filter { 44 | inherit root; 45 | include = [ 46 | "package.json" 47 | "src/components/widget.jsx" 48 | "src/innerdir" 49 | (nix-filter.matchExt "js") 50 | ]; 51 | }; 52 | expected = [ 53 | "package.json" 54 | "src" 55 | "src/components" 56 | "src/components/widget.jsx" 57 | "src/innerdir" 58 | "src/innerdir/inner.js" 59 | "src/main.js" 60 | ]; 61 | }; 62 | 63 | with-matchName = rec { 64 | root = ./fixture1; 65 | actual = nix-filter { 66 | inherit root; 67 | exclude = [ 68 | (nix-filter.matchName "src") 69 | ]; 70 | }; 71 | expected = [ 72 | "README.md" 73 | "package.json" 74 | ]; 75 | }; 76 | 77 | with-inDirectory = rec { 78 | root = ./fixture1; 79 | actual = nix-filter { 80 | inherit root; 81 | include = [ 82 | (nix-filter.inDirectory "src") 83 | (nix-filter.inDirectory "READ") 84 | ]; 85 | }; 86 | expected = [ 87 | "src" 88 | "src/main.js" 89 | "src/components" 90 | "src/components/widget.jsx" 91 | "src/innerdir" 92 | "src/innerdir/inner.js" 93 | ]; 94 | }; 95 | 96 | with-inDirectory2 = rec { 97 | root = ./fixture1; 98 | actual = nix-filter { 99 | inherit root; 100 | include = [ 101 | (nix-filter.inDirectory "src") 102 | (nix-filter.inDirectory "READ") 103 | ]; 104 | exclude = [ 105 | (nix-filter.inDirectory ./fixture1/src/innerdir) 106 | ]; 107 | }; 108 | expected = [ 109 | "src" 110 | "src/main.js" 111 | "src/components" 112 | "src/components/widget.jsx" 113 | ]; 114 | }; 115 | 116 | excluding-paths-keeps-the-parents = rec { 117 | root = ./fixture1; 118 | actual = nix-filter { 119 | inherit root; 120 | include = with nix-filter; [ 121 | (inDirectory "src") 122 | ]; 123 | exclude = with nix-filter; [ 124 | "src/components" 125 | "src/innerdir/inner.js" 126 | ]; 127 | }; 128 | expected = [ 129 | "src" 130 | "src/innerdir" 131 | "src/main.js" 132 | ]; 133 | }; 134 | 135 | including-a-file-also-includes-the-parents = rec { 136 | root = ./fixture1; 137 | actual = nix-filter { 138 | inherit root; 139 | include = [ 140 | "src/innerdir/inner.js" 141 | ]; 142 | }; 143 | expected = [ 144 | "src" 145 | "src/innerdir" 146 | "src/innerdir/inner.js" 147 | ]; 148 | }; 149 | 150 | including-a-directory-also-includes-the-parents = rec { 151 | root = ./fixture1; 152 | actual = nix-filter { 153 | inherit root; 154 | include = [ 155 | "src/components" 156 | ]; 157 | exclude = [ 158 | (nix-filter.matchExt "jsx") 159 | ]; 160 | }; 161 | expected = [ 162 | "src" 163 | "src/components" 164 | ]; 165 | }; 166 | 167 | including-a-directory-also-includes-the-childs = rec { 168 | root = ./fixture1; 169 | actual = nix-filter { 170 | inherit root; 171 | include = [ 172 | "src/innerdir" 173 | ]; 174 | }; 175 | expected = [ 176 | "src" 177 | "src/innerdir" 178 | "src/innerdir/inner.js" 179 | ]; 180 | }; 181 | 182 | exclude-string-or-matchExt-and-inDirectory = rec { 183 | root = ./fixture1; 184 | actual = nix-filter { 185 | inherit root; 186 | include = with nix-filter; [ 187 | (inDirectory "src") 188 | ]; 189 | exclude = with nix-filter; [ 190 | (or_ 191 | "src/components/widget.jsx" 192 | (and (matchExt "js") (inDirectory "src/innerdir")) 193 | ) 194 | ]; 195 | }; 196 | expected = [ 197 | "src" 198 | "src/components" 199 | "src/innerdir" 200 | "src/main.js" 201 | ]; 202 | }; 203 | 204 | in-nix-store = rec { 205 | # Use the string interpolation to force the path into the store 206 | root = "${./fixture1}"; 207 | actual = nix-filter { 208 | inherit root; 209 | include = [ 210 | "src/innerdir" 211 | ]; 212 | }; 213 | expected = [ 214 | "src" 215 | "src/innerdir" 216 | "src/innerdir/inner.js" 217 | ]; 218 | }; 219 | 220 | combiners = rec { 221 | root = ./fixture1; 222 | actual = nix-filter { 223 | inherit root; 224 | include = with nix-filter; [ 225 | (and isDirectory (inDirectory "src")) 226 | ]; 227 | }; 228 | expected = [ 229 | "src" 230 | "src/components" 231 | "src/innerdir" 232 | ]; 233 | }; 234 | 235 | trace = rec { 236 | root = ./fixture1; 237 | actual = nix-filter { 238 | inherit root; 239 | include = [ 240 | nix-filter.traceUnmatched 241 | ]; 242 | }; 243 | expected = [ ]; 244 | }; 245 | } 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |