├── .editorconfig ├── .envrc ├── .gitignore ├── LICENSE ├── README.md ├── devshell ├── flake.lock └── flake.nix ├── flake.lock ├── flake.nix ├── screenshots ├── enums.png ├── functions.png ├── nested-structs.png ├── simple.png └── structs.png ├── struct.nix ├── tests └── default.nix └── treefmt.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | 12 | # Ignore diffs/patches 13 | [*.{diff,patch}] 14 | end_of_line = unset 15 | insert_final_newline = unset 16 | trim_trailing_whitespace = unset 17 | indent_size = unset 18 | 19 | [*.md] 20 | max_line_length = off 21 | trim_trailing_whitespace = false 22 | 23 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | # reload when these files change 2 | watch_file devshell/flake.nix 3 | watch_file devshell/flake.lock 4 | 5 | { 6 | # shell gc root dir 7 | mkdir -p "$(direnv_layout_dir)" 8 | eval "$(nix print-dev-env ./devshell\#__default --no-update-lock-file --no-write-lock-file --profile $(direnv_layout_dir)/flake-profile)" 9 | } 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | /result* 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Vincent Ambo 4 | Copyright (c) 2020-2023 The TVL Authors 5 | Copyright (c) 2021-2023 The Divnix Authors 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yants 2 | 3 | This is a tiny type-checker for data in Nix, written in Nix. 4 | 5 | # Features 6 | 7 | - Checking of primitive types (`int`, `string` etc.) 8 | - Checking polymorphic types (`option`, `list`, `either`) 9 | - Defining & checking struct/record types 10 | - Defining & matching enum types 11 | - Defining & matching sum types 12 | - Defining function signatures (including curried functions) 13 | - Types are composable! `option string`! `list (either int (option float))`! 14 | - Type errors also compose! 15 | 16 | Currently lacking: 17 | 18 | - Any kind of inference 19 | - Convenient syntax for attribute-set function signatures 20 | 21 | ## Primitives & simple polymorphism 22 | 23 | ![simple](screenshots/simple.png) 24 | 25 | ## Structs 26 | 27 | ![structs](screenshots/structs.png) 28 | 29 | ## Nested structs! 30 | 31 | ![nested structs](screenshots/nested-structs.png) 32 | 33 | ## Enums! 34 | 35 | ![enums](screenshots/enums.png) 36 | 37 | ## Functions! 38 | 39 | ![functions](screenshots/functions.png) 40 | 41 | # Usage 42 | 43 | 1. Import into scope with `with`: 44 | 45 | ```nix 46 | { 47 | inputs.yants.url = "github:divnix/yants"; 48 | outputs = inputs: { 49 | someType = with inputs.yants; # code using yants 50 | }; 51 | } 52 | ``` 53 | 54 | 2. Import into scope and add log context: 55 | 56 | ```nix 57 | { 58 | inputs.yants.url = "github:divnix/yants"; 59 | outputs = inputs: let 60 | rootLogYants = inputs.yants "my-lib"; 61 | leafLogYants = rootLogYants "leaf"; 62 | in { 63 | someType = with leafLogYants; # code using yants 64 | }; 65 | } 66 | ``` 67 | 68 | Please see my [Nix one-pager](https://github.com/tazjin/nix-1p) for more generic 69 | information about the Nix language and what the above constructs mean. 70 | 71 | # Stability 72 | 73 | The current API of Yants is **not yet** considered stable, but it works fine and 74 | should continue to do so even if used at an older version. 75 | 76 | Yants' tests use Nix versions above 2.6 - compatibility with older versions is 77 | not guaranteed. 78 | -------------------------------------------------------------------------------- /devshell/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devshell": { 4 | "inputs": { 5 | "flake-utils": "flake-utils", 6 | "nixpkgs": "nixpkgs" 7 | }, 8 | "locked": { 9 | "lastModified": 1658746384, 10 | "narHash": "sha256-CCJcoMOcXyZFrV1ag4XMTpAPjLWb4Anbv+ktXFI1ry0=", 11 | "owner": "numtide", 12 | "repo": "devshell", 13 | "rev": "0ffc7937bb5e8141af03d462b468bd071eb18e1b", 14 | "type": "github" 15 | }, 16 | "original": { 17 | "owner": "numtide", 18 | "repo": "devshell", 19 | "type": "github" 20 | } 21 | }, 22 | "flake-utils": { 23 | "locked": { 24 | "lastModified": 1642700792, 25 | "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", 26 | "owner": "numtide", 27 | "repo": "flake-utils", 28 | "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "owner": "numtide", 33 | "repo": "flake-utils", 34 | "type": "github" 35 | } 36 | }, 37 | "flake-utils_2": { 38 | "locked": { 39 | "lastModified": 1659877975, 40 | "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", 41 | "owner": "numtide", 42 | "repo": "flake-utils", 43 | "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgs": { 53 | "locked": { 54 | "lastModified": 1643381941, 55 | "narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=", 56 | "owner": "NixOS", 57 | "repo": "nixpkgs", 58 | "rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5", 59 | "type": "github" 60 | }, 61 | "original": { 62 | "owner": "NixOS", 63 | "ref": "nixpkgs-unstable", 64 | "repo": "nixpkgs", 65 | "type": "github" 66 | } 67 | }, 68 | "nixpkgs_2": { 69 | "locked": { 70 | "lastModified": 1660464579, 71 | "narHash": "sha256-kzA9rwh0wS6CwUnFHAQ7dhJCowMPfRbvixVeOKnUmjo=", 72 | "owner": "nixos", 73 | "repo": "nixpkgs", 74 | "rev": "b02538b16f6c5e1dbfce1033b27946e25c019b3b", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "nixos", 79 | "ref": "nixpkgs-unstable", 80 | "repo": "nixpkgs", 81 | "type": "github" 82 | } 83 | }, 84 | "root": { 85 | "inputs": { 86 | "devshell": "devshell", 87 | "flake-utils": "flake-utils_2", 88 | "nixpkgs": "nixpkgs_2" 89 | } 90 | } 91 | }, 92 | "root": "root", 93 | "version": 7 94 | } 95 | -------------------------------------------------------------------------------- /devshell/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Yants devshell"; 3 | inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 4 | inputs.devshell.url = "github:numtide/devshell"; 5 | inputs.flake-utils.url = "github:numtide/flake-utils"; 6 | outputs = inputs: 7 | inputs.flake-utils.lib.eachSystem ["x86_64-linux" "x86_64-darwin"] ( 8 | system: let 9 | devshell = inputs.devshell.legacyPackages.${system}; 10 | nixpkgs = inputs.nixpkgs.legacyPackages.${system}; 11 | in { 12 | devShells.__default = devshell.mkShell { 13 | name = "Yants"; 14 | packages = [ 15 | nixpkgs.alejandra 16 | nixpkgs.treefmt 17 | nixpkgs.shfmt 18 | nixpkgs.nodePackages.prettier 19 | ]; 20 | }; 21 | } 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1660438583, 6 | "narHash": "sha256-rJUTYxFKlWUJI3njAwEc1pKAVooAViZGJvsgqfh/q/E=", 7 | "owner": "nix-community", 8 | "repo": "nixpkgs.lib", 9 | "rev": "bbd8f7cd87d0b29294ef3072ffdbd61d60f05da4", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nix-community", 14 | "repo": "nixpkgs.lib", 15 | "type": "github" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # SPDX-License-Identifier: Apache-2.0 3 | # 4 | # Provides a "type-system" for Nix that provides various primitive & 5 | # polymorphic types as well as the ability to define & check records. 6 | # 7 | # All types (should) compose as expected. 8 | { 9 | description = "Yet Another Nix Type System"; 10 | inputs.nixpkgs.url = "github:nix-community/nixpkgs.lib"; 11 | outputs = inputs: 12 | with builtins; let 13 | lib = inputs.nixpkgs.lib; 14 | prettyPrintFunctor = lib.generators.toPretty {}; 15 | prettyPrint = val: let 16 | val' = 17 | if lib.isAttrs val && lib.hasAttr "__functor" val 18 | then { 19 | __pretty = val: let 20 | set = prettyPrint (builtins.removeAttrs val ["__functor"]); 21 | functor = prettyPrintFunctor val.__functor; 22 | in "${set} with __functor = ${functor}"; 23 | inherit val; 24 | } 25 | else val; 26 | in 27 | lib.generators.toPretty {allowPrettyValues = true;} val'; 28 | throw' = self: message: 29 | if self ? logContext && self.logContext != null 30 | then throw "[${self.logContext}]: ${message}" 31 | else throw message; 32 | # typedef' :: struct { 33 | # name = string; 34 | # checkType = function; (a -> result) 35 | # checkToBool = option function; (result -> bool) 36 | # toError = option function; (a -> result -> string) 37 | # def = option any; 38 | # match = option function; 39 | # } -> type 40 | # -> (a -> b) 41 | # -> (b -> bool) 42 | # -> (a -> b -> string) 43 | # -> type 44 | # 45 | # This function creates an attribute set that acts as a type. 46 | # 47 | # It receives a type name, a function that is used to perform a 48 | # check on an arbitrary value, a function that can translate the 49 | # return of that check to a boolean that informs whether the value 50 | # is type-conformant, and a function that can construct error 51 | # messages from the check result. 52 | # 53 | # This function is the low-level primitive used to create types. For 54 | # many cases the higher-level 'typedef' function is more appropriate. 55 | _typedef' = logContext: { 56 | name, 57 | checkType, 58 | checkToBool ? (result: result.ok), 59 | toError ? (_: result: result.err), 60 | def ? null, 61 | match ? null, 62 | }: { 63 | inherit name logContext checkToBool toError; 64 | # check :: a -> bool 65 | # 66 | # This function is used to determine whether a given type is 67 | # conformant. 68 | check = value: checkToBool (checkType value); 69 | # checkType :: a -> struct { ok = bool; err = option string; } 70 | # 71 | # This function checks whether the passed value is type conformant 72 | # and returns an optional type error string otherwise. 73 | inherit checkType; 74 | # __functor :: a -> a 75 | # 76 | # This function checks whether the passed value is type conformant 77 | # and throws an error if it is not. 78 | # 79 | # The name of this function is a special attribute in Nix that 80 | # makes it possible to execute a type attribute set like a normal 81 | # function. 82 | __functor = self: value: let 83 | result = self.checkType value; 84 | in 85 | if checkToBool result 86 | then value 87 | else throw' self (toError value result); 88 | }; 89 | typeError = type: val: "expected type '${type}', but value '${prettyPrint val}' is of type '${typeOf val}'"; 90 | # typedef :: string -> string -> (a -> bool) -> type 91 | # 92 | # typedef is the simplified version of typedef' which uses a default 93 | # error message constructor. 94 | _typedef = logContext: name: check: 95 | _typedef' logContext { 96 | inherit name; 97 | checkType = v: let 98 | res = check v; 99 | in 100 | {ok = res;} 101 | // (lib.optionalAttrs (!res) {err = typeError name v;}); 102 | }; 103 | checkEach = name: t: l: 104 | foldl' ( 105 | acc: e: let 106 | res = t.checkType e; 107 | isT = t.checkToBool res; 108 | in { 109 | ok = acc.ok && isT; 110 | err = 111 | if isT 112 | then acc.err 113 | else acc.err + "${prettyPrint e}: ${t.toError e res}\n"; 114 | } 115 | ) { 116 | ok = true; 117 | err = "expected type ${name}, but found:\n"; 118 | } 119 | l; 120 | in 121 | lib.fix' ( 122 | self: let 123 | typedef = _typedef (self.logContext or null); 124 | typedef' = _typedef' (self.logContext or null); 125 | in { 126 | # Primitive types 127 | any = typedef "any" (_: true); 128 | unit = typedef "unit" (v: v == {}); 129 | int = typedef "int" isInt; 130 | bool = typedef "bool" isBool; 131 | float = typedef "float" isFloat; 132 | string = typedef "string" isString; 133 | path = typedef "path" (x: typeOf x == "path"); 134 | drv = typedef "derivation" (x: isAttrs x && x ? "type" && x.type == "derivation"); 135 | function = typedef "function" ( 136 | x: 137 | isFunction x 138 | || (isAttrs x && x ? "__functor" && isFunction x.__functor) 139 | ); 140 | functionWithArgs = args: let 141 | args' = (self.attrs self.bool) args; 142 | in 143 | typedef' rec { 144 | name = "function"; 145 | checkType = x: let 146 | # only lib.trivial.functionArgs does support __functor 147 | realArgs = lib.trivial.functionArgs (self.function x); 148 | in { 149 | ok = realArgs == args'; 150 | err = "expected ${prettyPrintFunctor (lib.trivial.setFunctionArgs lib.id args')}, but arguments and/or defaults do not conform: ${prettyPrintFunctor (self.function x)}"; 151 | }; 152 | }; 153 | # Type for types themselves. Useful when defining polymorphic types. 154 | type = typedef "type" ( 155 | x: 156 | isAttrs x 157 | && hasAttr "name" x 158 | && self.string.check x.name 159 | && hasAttr "checkType" x 160 | && self.function.check x.checkType 161 | && hasAttr "checkToBool" x 162 | && self.function.check x.checkToBool 163 | && hasAttr "toError" x 164 | && self.function.check x.toError 165 | ); 166 | # Polymorphic types 167 | option = t: 168 | typedef' rec { 169 | name = "option<${t.name}>"; 170 | checkType = v: let 171 | res = t.checkType v; 172 | in { 173 | ok = isNull v || (self.type t).checkToBool res; 174 | err = 175 | "expected type ${name}, but value does not conform to '${ 176 | t.name 177 | }': " 178 | + t.toError v res; 179 | }; 180 | }; 181 | eitherN = tn: 182 | typedef "either<${concatStringsSep ", " (map (x: x.name) tn)}>" (x: any (t: (self.type t).check x) tn); 183 | either = t1: t2: self.eitherN [t1 t2]; 184 | list = t: 185 | typedef' rec { 186 | name = "list<${t.name}>"; 187 | checkType = v: 188 | if isList v 189 | then checkEach name (self.type t) v 190 | else { 191 | ok = false; 192 | err = typeError name v; 193 | }; 194 | }; 195 | attrs = t: 196 | typedef' rec { 197 | name = "attrs<${t.name}>"; 198 | checkType = v: 199 | if isAttrs v 200 | then checkEach name (self.type t) (attrValues v) 201 | else { 202 | ok = false; 203 | err = typeError name v; 204 | }; 205 | }; 206 | # Structs / record types 207 | # 208 | # Checks that all fields match their declared types, no optional 209 | # fields are missing and no unexpected fields occur in the struct. 210 | # 211 | # Anonymous structs are supported (e.g. for nesting) by omitting the 212 | # name. 213 | # 214 | inherit (import ./struct.nix {inherit self typedef' typeError;}) struct openStruct; 215 | 216 | enum = let 217 | plain = name: def: 218 | typedef' { 219 | inherit name def; 220 | checkType = x: isString x && elem x def; 221 | checkToBool = x: x; 222 | toError = value: _: "'${prettyPrint value} is not a member of enum ${name}"; 223 | }; 224 | enum' = name: def: 225 | lib.fix ( 226 | e: 227 | (plain name def) 228 | // { 229 | match = x: actions: 230 | deepSeq (map e (attrNames actions)) ( 231 | let 232 | actionKeys = attrNames actions; 233 | missing = 234 | foldl' ( 235 | m: k: 236 | if (elem k actionKeys) 237 | then m 238 | else m ++ [k] 239 | ) [] 240 | def; 241 | in 242 | if (length missing) > 0 243 | then 244 | throw' self "Missing match action for members: ${ 245 | prettyPrint missing 246 | }" 247 | else actions."${e x}" 248 | ); 249 | } 250 | ); 251 | in 252 | arg: 253 | if isString arg 254 | then (enum' arg) 255 | else (enum' "anon" arg); 256 | # Sum types 257 | # 258 | # The representation of a sum type is an attribute set with only one 259 | # value, where the key of the value denotes the variant of the type. 260 | sum = let 261 | plain = name: def: 262 | typedef' { 263 | inherit name def; 264 | checkType = ( 265 | x: let 266 | variant = elemAt (attrNames x) 0; 267 | in 268 | if 269 | isAttrs x 270 | && length (attrNames x) == 1 271 | && hasAttr variant def 272 | then let 273 | t = def."${variant}"; 274 | v = x."${variant}"; 275 | res = t.checkType v; 276 | in 277 | if t.checkToBool res 278 | then {ok = true;} 279 | else { 280 | ok = false; 281 | err = 282 | "while checking '${name}' variant '${variant}': " 283 | + t.toError v res; 284 | } 285 | else { 286 | ok = false; 287 | err = typeError name x; 288 | } 289 | ); 290 | }; 291 | sum' = name: def: 292 | lib.fix ( 293 | s: 294 | (plain name def) 295 | // { 296 | match = x: actions: let 297 | variant = deepSeq (s x) (elemAt (attrNames x) 0); 298 | actionKeys = attrNames actions; 299 | defKeys = attrNames def; 300 | missing = 301 | foldl' ( 302 | m: k: 303 | if (elem k actionKeys) 304 | then m 305 | else m ++ [k] 306 | ) [] 307 | defKeys; 308 | in 309 | if (length missing) > 0 310 | then 311 | throw' self "Missing match action for variants: ${ 312 | prettyPrint missing 313 | }" 314 | else actions."${variant}" x."${variant}"; 315 | } 316 | ); 317 | in 318 | arg: 319 | if isString arg 320 | then (sum' arg) 321 | else (sum' "anon" arg); 322 | # Typed function definitions 323 | # 324 | # These definitions wrap the supplied function in type-checking 325 | # forms that are evaluated when the function is called. 326 | # 327 | # Note that typed functions themselves are not types and can not be 328 | # used to check values for conformity. 329 | defun = let 330 | mkFunc = sig: f: { 331 | inherit sig; 332 | __toString = self: 333 | foldl' (s: t: "${s} -> ${t.name}") "λ :: ${(head self.sig).name}" (tail self.sig); 334 | __functor = _: f; 335 | }; 336 | defun' = sig: func: 337 | if length sig > 2 338 | then mkFunc sig (x: defun' (tail sig) (func ((head sig) x))) 339 | else mkFunc sig (x: ((head (tail sig)) (func ((head sig) x)))); 340 | in 341 | sig: func: 342 | if length sig < 2 343 | then 344 | ( 345 | throw' self "Signature must at least have two types (a -> b)" 346 | ) 347 | else defun' sig func; 348 | # Restricting types 349 | # 350 | # `restrict` wraps a type `t`, and uses a predicate `pred` to further 351 | # restrict the values, giving the restriction a descriptive `name`. 352 | # 353 | # First, the wrapped type definition is checked (e.g. int) and then the 354 | # value is checked with the predicate, so the predicate can already 355 | # depend on the value being of the wrapped type. 356 | restrict = name: pred: t: let 357 | restriction = "${t.name}[${name}]"; 358 | in 359 | typedef' { 360 | name = restriction; 361 | checkType = v: let 362 | res = t.checkType v; 363 | in 364 | if !(t.checkToBool res) 365 | then res 366 | else let 367 | iok = pred v; 368 | in 369 | if isBool iok 370 | then { 371 | ok = iok; 372 | err = "${prettyPrint v} does not conform to restriction '${restriction}'"; 373 | } 374 | else 375 | # use throw here to avoid spamming the build log 376 | throw' self "restriction '${restriction}' predicate returned unexpected value '${ 377 | prettyPrint iok 378 | }' instead of boolean"; 379 | }; 380 | # __functor 381 | # 382 | # `__functor` wraps yants to add a log context to thrown error messages. 383 | # 384 | # With a log context, error messages look like so: 385 | # error: [logcontext]: ... 386 | # 387 | # Usage with for a log path "sohe:things:break": 388 | # with yants "some" "things" "break"; 389 | __functor = self: context: 390 | lib.fix' ( 391 | lib.extends ( 392 | _: super: { 393 | logContext = 394 | ( 395 | if super ? logContext && super.logContext != null 396 | then "${super.logContext}:" 397 | else "" 398 | ) 399 | + context; 400 | } 401 | ) 402 | self.__unfix__ 403 | ); 404 | } 405 | ); 406 | } 407 | -------------------------------------------------------------------------------- /screenshots/enums.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divnix/yants/8f0da0dba57149676aa4817ec0c880fbde7a648d/screenshots/enums.png -------------------------------------------------------------------------------- /screenshots/functions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divnix/yants/8f0da0dba57149676aa4817ec0c880fbde7a648d/screenshots/functions.png -------------------------------------------------------------------------------- /screenshots/nested-structs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divnix/yants/8f0da0dba57149676aa4817ec0c880fbde7a648d/screenshots/nested-structs.png -------------------------------------------------------------------------------- /screenshots/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divnix/yants/8f0da0dba57149676aa4817ec0c880fbde7a648d/screenshots/simple.png -------------------------------------------------------------------------------- /screenshots/structs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divnix/yants/8f0da0dba57149676aa4817ec0c880fbde7a648d/screenshots/structs.png -------------------------------------------------------------------------------- /struct.nix: -------------------------------------------------------------------------------- 1 | {self, typedef', typeError}: 2 | # Struct checking is more involved than the simpler types above. 3 | # To make the actual type definition more readable, several 4 | # helpers are defined below. 5 | with builtins; let 6 | # checkField checks an individual field of the struct against 7 | # its definition and creates a typecheck result. These results 8 | # are aggregated during the actual checking. 9 | checkField = def: name: value: let 10 | result = def.checkType value; 11 | in rec { 12 | ok = def.checkToBool result; 13 | err = 14 | if !ok && isNull value 15 | then "missing required ${def.name} field '${name}'\n" 16 | else "field '${name}': ${def.toError value result}\n"; 17 | }; 18 | # checkExtraneous determines whether a (closed) struct contains 19 | # any fields that are not part of the definition. 20 | checkExtraneous = def: has: acc: 21 | if (length has) == 0 22 | then acc 23 | else if (hasAttr (head has) def) 24 | then checkExtraneous def (tail has) acc 25 | else 26 | checkExtraneous def (tail has) { 27 | ok = false; 28 | err = 29 | acc.err + "unexpected struct field '${head has}'\n"; 30 | }; 31 | # checkStruct combines all structure checks and creates one 32 | # typecheck result from them 33 | checkStruct = isClosed: def: value: let 34 | init = { 35 | ok = true; 36 | err = ""; 37 | }; 38 | extraneous = checkExtraneous def (attrNames value) init; 39 | checkedFields = map ( 40 | n: let 41 | v = 42 | if hasAttr n value 43 | then value."${n}" 44 | else null; 45 | in 46 | checkField def."${n}" n v 47 | ) (attrNames def); 48 | combined = 49 | foldl' ( 50 | acc: res: { 51 | ok = acc.ok && res.ok; 52 | err = 53 | if !res.ok 54 | then acc.err + res.err 55 | else acc.err; 56 | } 57 | ) 58 | init 59 | checkedFields; 60 | in { 61 | ok = 62 | combined.ok 63 | && ( 64 | if isClosed 65 | then extraneous.ok 66 | else true 67 | ); 68 | err = 69 | combined.err 70 | + ( 71 | if isClosed 72 | then extraneous.err 73 | else "" 74 | ); 75 | }; 76 | struct' = name: isClosed: def: 77 | typedef' { 78 | inherit name def; 79 | checkType = value: 80 | if isAttrs value 81 | then (checkStruct isClosed (self.attrs self.type def) value) 82 | else { 83 | ok = false; 84 | err = typeError name value; 85 | }; 86 | toError = _: result: 87 | "expected '${name}'-struct, but found:\n" + result.err; 88 | }; 89 | in { 90 | struct = arg: 91 | if isString arg 92 | then struct' arg true 93 | else struct' "anon" true arg; 94 | 95 | openStruct = arg: 96 | if isString arg 97 | then struct' arg false 98 | else struct' "anon" false arg; 99 | } 100 | -------------------------------------------------------------------------------- /tests/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | depot, 3 | pkgs, 4 | ... 5 | }: 6 | with depot.nix.yants; 7 | # Note: Derivations are not included in the tests below as they cause 8 | # issues with deepSeq. 9 | let 10 | inherit 11 | (depot.nix.runTestsuite) 12 | runTestsuite 13 | it 14 | assertEq 15 | assertThrows 16 | assertDoesNotThrow 17 | ; 18 | # this derivation won't throw if evaluated with deepSeq 19 | # unlike most things even remotely related with nixpkgs 20 | trivialDerivation = derivation { 21 | name = "trivial-derivation"; 22 | inherit (pkgs.stdenv) system; 23 | builder = "/bin/sh"; 24 | args = ["-c" "echo hello > $out"]; 25 | }; 26 | testPrimitives = it "checks that all primitive types match" [ 27 | (assertDoesNotThrow "unit type" (unit {})) 28 | (assertDoesNotThrow "int type" (int 15)) 29 | (assertDoesNotThrow "bool type" (bool false)) 30 | (assertDoesNotThrow "float type" (float 13.37)) 31 | (assertDoesNotThrow "string type" (string "Hello!")) 32 | (assertDoesNotThrow "function type" (function (x: x * 2))) 33 | (assertDoesNotThrow "path type" (path /nix)) 34 | (assertDoesNotThrow "derivation type" (drv trivialDerivation)) 35 | ]; 36 | testPoly = it "checks that polymorphic types work as intended" [ 37 | (assertDoesNotThrow "option type" (option int null)) 38 | (assertDoesNotThrow "list type" (list string ["foo" "bar"])) 39 | (assertDoesNotThrow "either type" (either int float 42)) 40 | ]; 41 | # Test that structures work as planned. 42 | person = struct "person" { 43 | name = string; 44 | age = int; 45 | contact = option ( 46 | struct { 47 | email = string; 48 | phone = option string; 49 | } 50 | ); 51 | }; 52 | testStruct = it "checks that structures work as intended" [ 53 | ( 54 | assertDoesNotThrow "person struct" ( 55 | person { 56 | name = "Brynhjulf"; 57 | age = 42; 58 | contact.email = "brynhjulf@yants.nix"; 59 | } 60 | ) 61 | ) 62 | ]; 63 | # Test enum definitions & matching 64 | colour = enum "colour" ["red" "blue" "green"]; 65 | colourMatcher = { 66 | red = "It is in fact red!"; 67 | blue = "It should not be blue!"; 68 | green = "It should not be green!"; 69 | }; 70 | testEnum = it "checks enum definitions and matching" [ 71 | ( 72 | assertEq "enum is matched correctly" "It is in fact red!" (colour.match "red" colourMatcher) 73 | ) 74 | ( 75 | assertThrows "out of bounds enum fails" ( 76 | colour.match "alpha" (colourMatcher // {alpha = "This should never happen";}) 77 | ) 78 | ) 79 | ]; 80 | # Test sum type definitions 81 | creature = sum "creature" { 82 | human = struct { 83 | name = string; 84 | age = option int; 85 | }; 86 | pet = enum "pet" ["dog" "lizard" "cat"]; 87 | }; 88 | some-human = creature { 89 | human = { 90 | name = "Brynhjulf"; 91 | age = 42; 92 | }; 93 | }; 94 | testSum = it "checks sum types definitions and matching" [ 95 | (assertDoesNotThrow "creature sum type" some-human) 96 | ( 97 | assertEq "sum type is matched correctly" "It's a human named Brynhjulf" ( 98 | creature.match some-human { 99 | human = v: "It's a human named ${v.name}"; 100 | pet = v: "It's not supposed to be a pet!"; 101 | } 102 | ) 103 | ) 104 | ]; 105 | # Test curried function definitions 106 | func = defun [string int string] (name: age: "${name} is ${toString age} years old"); 107 | testFunctions = it "checks function definitions" [(assertDoesNotThrow "function application" (func "Brynhjulf" 42))]; 108 | # Test that all types are types. 109 | assertIsType = name: t: assertDoesNotThrow "${name} is a type" (type t); 110 | testTypes = it "checks that all types are types" [ 111 | (assertIsType "any" any) 112 | (assertIsType "bool" bool) 113 | (assertIsType "drv" drv) 114 | (assertIsType "float" float) 115 | (assertIsType "int" int) 116 | (assertIsType "string" string) 117 | (assertIsType "path" path) 118 | (assertIsType "attrs int" (attrs int)) 119 | (assertIsType "eitherN [ ... ]" (eitherN [int string bool])) 120 | (assertIsType "either int string" (either int string)) 121 | (assertIsType "enum [ ... ]" (enum ["foo" "bar"])) 122 | (assertIsType "list string" (list string)) 123 | (assertIsType "option int" (option int)) 124 | (assertIsType "option (list string)" (option (list string))) 125 | ( 126 | assertIsType "struct { ... }" ( 127 | struct { 128 | a = int; 129 | b = option string; 130 | } 131 | ) 132 | ) 133 | ( 134 | assertIsType "sum { ... }" ( 135 | sum { 136 | a = int; 137 | b = option string; 138 | } 139 | ) 140 | ) 141 | ]; 142 | testRestrict = it "checks restrict types" [ 143 | (assertDoesNotThrow "< 42" ((restrict "< 42" (i: i < 42) int) 25)) 144 | ( 145 | assertDoesNotThrow "list length < 3" ( 146 | (restrict "not too long" (l: builtins.length l < 3) (list int)) [1 2] 147 | ) 148 | ) 149 | ( 150 | assertDoesNotThrow "list eq 5" (list (restrict "eq 5" (v: v == 5) any) [5 5 5]) 151 | ) 152 | ]; 153 | in 154 | runTestsuite "yants" [ 155 | testPrimitives 156 | testPoly 157 | testStruct 158 | testEnum 159 | testSum 160 | testFunctions 161 | testTypes 162 | testRestrict 163 | ] 164 | -------------------------------------------------------------------------------- /treefmt.toml: -------------------------------------------------------------------------------- 1 | [formatter.nix] 2 | command = "alejandra" 3 | includes = ["*.nix"] 4 | 5 | [formatter.prettier] 6 | command = "prettier" 7 | options = ["--write"] 8 | includes = [ 9 | "*.css", 10 | "*.html", 11 | "*.js", 12 | "*.json", 13 | "*.jsx", 14 | "*.md", 15 | "*.mdx", 16 | "*.scss", 17 | "*.ts", 18 | "*.yaml", 19 | ] 20 | 21 | [formatter.shell] 22 | command = "shfmt" 23 | options = [ 24 | "-i", 25 | "2", # indent 2 26 | "-s", # simplify the code 27 | "-w", # write back to the file 28 | ] 29 | includes = ["*.sh"] 30 | 31 | --------------------------------------------------------------------------------