about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--users/sterni/nix/char/all-chars.bin2
-rw-r--r--users/sterni/nix/char/default.nix90
-rw-r--r--users/sterni/nix/char/tests/default.nix31
-rw-r--r--users/sterni/nix/flow/default.nix54
-rw-r--r--users/sterni/nix/flow/tests/default.nix49
-rw-r--r--users/sterni/nix/fun/default.nix45
-rw-r--r--users/sterni/nix/int/default.nix121
-rw-r--r--users/sterni/nix/int/tests/default.nix204
-rw-r--r--users/sterni/nix/string/default.nix71
-rw-r--r--users/sterni/nix/string/tests/default.nix53
-rw-r--r--users/sterni/nix/url/default.nix42
-rw-r--r--users/sterni/nix/url/tests/default.nix49
12 files changed, 811 insertions, 0 deletions
diff --git a/users/sterni/nix/char/all-chars.bin b/users/sterni/nix/char/all-chars.bin
new file mode 100644
index 0000000000..017b909e8e
--- /dev/null
+++ b/users/sterni/nix/char/all-chars.bin
@@ -0,0 +1,2 @@
+	
+
 !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~
\ No newline at end of file
diff --git a/users/sterni/nix/char/default.nix b/users/sterni/nix/char/default.nix
new file mode 100644
index 0000000000..e6b8d6d7f1
--- /dev/null
+++ b/users/sterni/nix/char/default.nix
@@ -0,0 +1,90 @@
+{ depot, lib, pkgs, ... }:
+
+let
+
+  inherit (depot.users.sterni.nix.flow)
+    cond
+    ;
+
+  inherit (depot.nix)
+    yants
+    ;
+
+  inherit (depot.users.sterni.nix)
+    string
+    ;
+
+  # A char is the atomic element of a nix string
+  # which is essentially an array of arbitrary bytes
+  # as long as they are not a NUL byte.
+  #
+  # A char is neither a byte nor a unicode codepoint!
+  char = yants.restrict "char" (s: builtins.stringLength s == 1) yants.string;
+
+  # integer representation of char
+  charval = yants.restrict "charval" (i: i >= 1 && i < 256) yants.int;
+
+  allChars = builtins.readFile ./all-chars.bin;
+
+  # Originally I searched a list for this, but came to the
+  # conclusion that this can never be fast enough in Nix.
+  # We therefore use a solution similar to infinisil's.
+  ordMap = builtins.listToAttrs
+    (lib.imap1 (i: v: { name = v; value = i; })
+      (string.toChars allChars));
+
+  # Note on performance:
+  # chr and ord have been benchmarked using the following cases:
+  #
+  #  builtins.map ord (lib.stringToCharacters allChars)
+  #  builtins.map chr (builtins.genList (int.add 1) 255
+  #
+  # The findings are as follows:
+  # 1. Searching through either strings using recursion is
+  #    unbearably slow in Nix, leading to evaluation times
+  #    of up to 3s for the following very small test case.
+  #    This is why we use the trusty attribute set for ord.
+  # 2. String indexing is much faster than list indexing which
+  #    is why we use the former for chr.
+  ord = c: ordMap."${c}";
+
+  chr = i: string.charAt (i - 1) allChars;
+
+  asciiAlpha = c:
+    let
+      v = ord c;
+    in (v >= 65 && v <= 90)
+    || (v >= 97 && v <= 122);
+
+  asciiNum = c:
+    let
+      v = ord c;
+    in v >= 48 && v <= 57;
+
+  asciiAlphaNum = c: asciiAlpha c || asciiNum c;
+
+in {
+  inherit
+    allChars
+    char
+    charval
+    ord
+    chr
+    asciiAlpha
+    asciiNum
+    asciiAlphaNum
+    ;
+
+  # originally I generated a nix file containing a list of
+  # characters, but infinisil uses a better way which I adapt
+  # which is using builtins.readFile instead of import.
+  __generateAllChars = pkgs.writers.writeC "generate-all-chars" {} ''
+    #include <stdio.h>
+
+    int main(void) {
+      for(int i = 1; i <= 0xff; i++) {
+        putchar(i);
+      }
+    }
+  '';
+}
diff --git a/users/sterni/nix/char/tests/default.nix b/users/sterni/nix/char/tests/default.nix
new file mode 100644
index 0000000000..49b439adbb
--- /dev/null
+++ b/users/sterni/nix/char/tests/default.nix
@@ -0,0 +1,31 @@
+{ depot, ... }:
+
+let
+  inherit (depot.nix.runTestsuite)
+    it
+    assertEq
+    runTestsuite
+    ;
+
+  inherit (depot.users.sterni.nix)
+    char
+    string
+    int
+    fun
+    ;
+
+  charList = string.toChars char.allChars;
+
+  testAllCharConversion = it "tests conversion of all chars" [
+    (assertEq "char.chr converts to char.allChars"
+      (builtins.genList (fun.rl char.chr (int.add 1)) 255)
+      charList)
+    (assertEq "char.ord converts from char.allChars"
+      (builtins.genList (int.add 1) 255)
+      (builtins.map char.ord charList))
+  ];
+
+in
+  runTestsuite "char" [
+    testAllCharConversion
+  ]
diff --git a/users/sterni/nix/flow/default.nix b/users/sterni/nix/flow/default.nix
new file mode 100644
index 0000000000..838e65bfb2
--- /dev/null
+++ b/users/sterni/nix/flow/default.nix
@@ -0,0 +1,54 @@
+{ depot, ... }:
+
+let
+
+  inherit (depot.nix)
+    yants
+    ;
+
+  # we must avoid evaluating any of the sublists
+  # as they may contain conditions that throw
+  condition = yants.restrict "condition"
+    (ls: builtins.length ls == 2)
+    (yants.list yants.any);
+
+  /* cond :: [ [ bool any ] ] -> any
+   *
+   * Like the common lisp macro: takes a list
+   * of two elemented lists whose first element
+   * is a boolean. The second element of the
+   * first list that has true as its first
+   * element is returned.
+   *
+   * Example:
+   *
+   * cond [
+   *   [ (builtins.isString true) 12 ]
+   *   [ (3 == 2) 13 ]
+   *   [ true 42 ]
+   * ]
+   *
+   * => 42
+   */
+  cond = conds:
+    if builtins.length conds == 0
+    then builtins.throw "cond: exhausted all conditions"
+    else
+      let
+        c = condition (builtins.head conds);
+      in
+        if builtins.head c
+        then builtins.elemAt c 1
+        else cond (builtins.tail conds);
+
+  # TODO(sterni): condf or magic
+  # like <nixpkgs/pkgs/build-support/coq/extra-lib.nix>
+
+  match = val: matcher: matcher."${val}";
+
+in {
+  inherit
+    cond
+    match
+    ;
+}
diff --git a/users/sterni/nix/flow/tests/default.nix b/users/sterni/nix/flow/tests/default.nix
new file mode 100644
index 0000000000..0bec4a3bd7
--- /dev/null
+++ b/users/sterni/nix/flow/tests/default.nix
@@ -0,0 +1,49 @@
+{ depot, ... }:
+
+let
+
+  inherit (depot.nix.runTestsuite)
+    runTestsuite
+    it
+    assertEq
+    assertThrows
+    ;
+
+  inherit (depot.users.sterni.nix.flow)
+    cond
+    match
+    ;
+
+  dontEval = builtins.throw "this should not get evaluated";
+
+  testCond = it "tests cond" [
+    (assertThrows "malformed cond list"
+      (cond [ [ true 1 2 ] [ false 1 ] ]))
+    (assertEq "last is true" "last"
+      (cond [
+        [ false dontEval]
+        [ false dontEval ]
+        [ true "last" ]
+      ]))
+    (assertEq "first is true" 1
+      (cond [
+        [ true 1 ]
+        [ true dontEval ]
+        [ true dontEval ]
+      ]))
+  ];
+
+  testMatch = it "tests match" [
+    (assertEq "basic match usage" 42
+      (match "answer" {
+        "answer" = 42;
+        "banana" = dontEval;
+        "maleur" = dontEval;
+      }))
+  ];
+
+in
+  runTestsuite "nix.flow" [
+    testCond
+    testMatch
+  ]
diff --git a/users/sterni/nix/fun/default.nix b/users/sterni/nix/fun/default.nix
new file mode 100644
index 0000000000..a7d173eb94
--- /dev/null
+++ b/users/sterni/nix/fun/default.nix
@@ -0,0 +1,45 @@
+{ depot, lib, ... }:
+
+let
+
+  inherit (lib)
+    id
+    ;
+
+  # Simple function composition,
+  # application is right to left.
+  rl = f1: f2:
+    (x: f1 (f2 x));
+
+  # Compose a list of functions,
+  # application is right to left.
+  rls = fs:
+    builtins.foldl' (fOut: f: lr f fOut) id fs;
+
+  # Simple function composition,
+  # application is left to right.
+  lr = f1: f2:
+    (x: f2 (f1 x));
+
+  # Compose a list of functions,
+  # application is left to right
+  lrs = fs: x:
+    builtins.foldl' (v: f: f v) x fs;
+
+in
+
+{
+  inherit (lib)
+    fix
+    flip
+    const
+    ;
+
+  inherit
+    id
+    rl
+    rls
+    lr
+    lrs
+    ;
+}
diff --git a/users/sterni/nix/int/default.nix b/users/sterni/nix/int/default.nix
new file mode 100644
index 0000000000..cd46fe7864
--- /dev/null
+++ b/users/sterni/nix/int/default.nix
@@ -0,0 +1,121 @@
+{ depot, lib, ... }:
+
+let
+
+  # TODO(sterni): implement nix.float and figure out which of these
+  #               functions can be split out into a common nix.num
+  #               library.
+
+  inherit (depot.users.sterni.nix)
+    string
+    ;
+
+  inherit (builtins)
+    bitOr
+    bitAnd
+    bitXor
+    mul
+    div
+    add
+    sub
+    ;
+
+  abs = i: if i < 0 then -i else i;
+
+  exp = base: pow:
+    if pow > 0
+    then base * (exp base (pow - 1))
+    else if pow < 0
+    then 1.0 / exp base (abs pow)
+    else 1;
+
+  bitShiftR = bit: count:
+    if count == 0
+    then bit
+    else div (bitShiftR bit (count - 1)) 2;
+
+  bitShiftL = bit: count:
+    if count == 0
+    then bit
+    else 2 * (bitShiftL bit (count - 1));
+
+  hexdigits = "0123456789ABCDEF";
+
+  toHex = int:
+    let
+      go = i:
+        if i == 0
+        then ""
+        else go (bitShiftR i 4)
+           + string.charAt (bitAnd i 15) hexdigits;
+      sign = lib.optionalString (int < 0) "-";
+    in
+      if int == 0
+      then "0"
+      else "${sign}${go (abs int)}";
+
+  fromHexMap = builtins.listToAttrs
+    (lib.imap0 (i: c: { name = c; value = i; })
+      (lib.stringToCharacters hexdigits));
+
+  fromHex = literal:
+    let
+      negative = string.charAt 0 literal == "-";
+      start = if negative then 1 else 0;
+      len = builtins.stringLength literal;
+      # reversed list of all digits
+      digits = builtins.genList
+        (i: string.charAt (len - 1 - i) literal)
+        (len - start);
+      parsed = builtins.foldl'
+        (v: d: {
+          val = v.val + (fromHexMap."${d}" * v.mul);
+          mul = v.mul * 16;
+        })
+        { val = 0; mul = 1; } digits;
+    in
+      if negative
+      then -parsed.val
+      else parsed.val;
+
+  # A nix integer is a 64bit signed integer
+  maxBound = 9223372036854775807;
+
+  # fun fact: -9223372036854775808 is the lower bound
+  # for a nix integer (as you would expect), but you can't
+  # use it as an integer literal or you'll be greeted with:
+  # error: invalid integer '9223372036854775808'
+  # This is because all int literals when parsing are
+  # positive, negative "literals" are positive literals
+  # which are preceded by the arithmetric negation operator.
+  minBound = -9223372036854775807 - 1;
+
+  odd = x: bitAnd x 1 == 1;
+  even = x: bitAnd x 1 == 0;
+
+  # div and mod behave like quot and rem in Haskell,
+  # i. e. they truncate towards 0
+  mod = a: b: let res = a / b; in a - (res * b);
+
+in {
+  inherit
+    maxBound
+    minBound
+    abs
+    exp
+    odd
+    even
+    add
+    sub
+    mul
+    div
+    mod
+    bitShiftR
+    bitShiftL
+    bitOr
+    bitAnd
+    bitXor
+    toHex
+    fromHex
+    ;
+}
diff --git a/users/sterni/nix/int/tests/default.nix b/users/sterni/nix/int/tests/default.nix
new file mode 100644
index 0000000000..966ac5a128
--- /dev/null
+++ b/users/sterni/nix/int/tests/default.nix
@@ -0,0 +1,204 @@
+{ depot, lib, ... }:
+
+let
+
+  inherit (depot.nix.runTestsuite)
+    runTestsuite
+    it
+    assertEq
+    ;
+
+  inherit (depot.users.sterni.nix)
+    int
+    string
+    fun
+    flow
+    ;
+
+  testBounds = it "checks minBound and maxBound" [
+    # this is gonna blow up in my face because
+    # integer overflow is undefined behavior in
+    # C++, so most likely anything could happen?
+    (assertEq "maxBound is the maxBound" true
+      (int.maxBound + 1 < int.maxBound))
+    (assertEq "minBound is the minBound" true
+      (int.minBound - 1 > int.minBound))
+    (assertEq "maxBound overflows to minBound"
+      (int.maxBound + 1)
+      int.minBound)
+    (assertEq "minBound overflows to maxBound"
+      (int.minBound - 1)
+      int.maxBound)
+  ];
+
+  expectedBytes = [
+    "00" "01" "02" "03" "04" "05" "06" "07" "08" "09" "0A" "0B" "0C" "0D" "0E" "0F"
+    "10" "11" "12" "13" "14" "15" "16" "17" "18" "19" "1A" "1B" "1C" "1D" "1E" "1F"
+    "20" "21" "22" "23" "24" "25" "26" "27" "28" "29" "2A" "2B" "2C" "2D" "2E" "2F"
+    "30" "31" "32" "33" "34" "35" "36" "37" "38" "39" "3A" "3B" "3C" "3D" "3E" "3F"
+    "40" "41" "42" "43" "44" "45" "46" "47" "48" "49" "4A" "4B" "4C" "4D" "4E" "4F"
+    "50" "51" "52" "53" "54" "55" "56" "57" "58" "59" "5A" "5B" "5C" "5D" "5E" "5F"
+    "60" "61" "62" "63" "64" "65" "66" "67" "68" "69" "6A" "6B" "6C" "6D" "6E" "6F"
+    "70" "71" "72" "73" "74" "75" "76" "77" "78" "79" "7A" "7B" "7C" "7D" "7E" "7F"
+    "80" "81" "82" "83" "84" "85" "86" "87" "88" "89" "8A" "8B" "8C" "8D" "8E" "8F"
+    "90" "91" "92" "93" "94" "95" "96" "97" "98" "99" "9A" "9B" "9C" "9D" "9E" "9F"
+    "A0" "A1" "A2" "A3" "A4" "A5" "A6" "A7" "A8" "A9" "AA" "AB" "AC" "AD" "AE" "AF"
+    "B0" "B1" "B2" "B3" "B4" "B5" "B6" "B7" "B8" "B9" "BA" "BB" "BC" "BD" "BE" "BF"
+    "C0" "C1" "C2" "C3" "C4" "C5" "C6" "C7" "C8" "C9" "CA" "CB" "CC" "CD" "CE" "CF"
+    "D0" "D1" "D2" "D3" "D4" "D5" "D6" "D7" "D8" "D9" "DA" "DB" "DC" "DD" "DE" "DF"
+    "E0" "E1" "E2" "E3" "E4" "E5" "E6" "E7" "E8" "E9" "EA" "EB" "EC" "ED" "EE" "EF"
+    "F0" "F1" "F2" "F3" "F4" "F5" "F6" "F7" "F8" "F9" "FA" "FB" "FC" "FD" "FE" "FF"
+  ];
+
+  hexByte = i: string.fit { width = 2; char = "0"; } (int.toHex i);
+
+  hexInts = [
+    { left = 0; right = "0"; }
+    { left = 1; right = "1"; }
+    { left = 11; right = "B"; }
+    { left = 123; right = "7B"; }
+    { left = 9000; right = "2328"; }
+    { left = 2323; right = "913"; }
+    { left = 4096; right = "1000"; }
+    { left = int.maxBound; right = "7FFFFFFFFFFFFFFF"; }
+    { left = int.minBound; right = "-8000000000000000"; }
+  ];
+
+  testHex = it "checks conversion to hex" (lib.flatten [
+    (lib.imap0 (i: hex: [
+      (assertEq "hexByte ${toString i} == ${hex}" (hexByte i) hex)
+      (assertEq "${toString i} == fromHex ${hex}" i (int.fromHex hex))
+    ]) expectedBytes)
+    (builtins.map ({ left, right }: [
+      (assertEq "toHex ${toString left} == ${right}" (int.toHex left) right)
+      (assertEq "${toString left} == fromHex ${right}" left (int.fromHex right))
+    ]) hexInts)
+  ]);
+
+  testBasic = it "checks basic int operations" [
+    (assertEq "122 is even" (int.even 122 && !(int.odd 122)) true)
+    (assertEq "123 is odd" (int.odd 123 && !(int.even 123)) true)
+    (assertEq "abs -4959" (int.abs (-4959)) 4959)
+  ];
+
+  expNumbers = [
+    { left = -3; right = 0.125; }
+    { left = -2; right = 0.25; }
+    { left = -1; right = 0.5; }
+    { left = 0; right = 1; }
+    { left = 1; right = 2; }
+    { left = 2; right = 4; }
+    { left = 3; right = 8; }
+    { left = 4; right = 16; }
+    { left = 5; right = 32; }
+    { left = 16; right = 65536; }
+  ];
+
+  testExp = it "checks exponentiation"
+    (builtins.map ({ left, right }:
+      assertEq
+        "2 ^ ${toString left} == ${toString right}"
+        (int.exp 2 left) right) expNumbers);
+
+  shifts = [
+    { a =   2; b = 5; c =   64; op = "<<"; }
+    { a =  -2; b = 5; c =  -64; op = "<<"; }
+    { a = 123; b = 4; c = 1968; op = "<<"; }
+    { a =   1; b = 8; c =  256; op = "<<"; }
+    { a = 256; b = 8; c =    1; op = ">>"; }
+    { a = 374; b = 2; c =   93; op = ">>"; }
+    { a =   2; b = 2; c =    0; op = ">>"; }
+    { a =  99; b = 9; c =    0; op = ">>"; }
+  ];
+
+  checkShift = { a, b, c, op }@args:
+    let
+      f = flow.match op {
+        "<<" = int.bitShiftL;
+        ">>" = int.bitShiftR;
+      };
+    in assertEq "${toString a} ${op} ${toString b} == ${toString c}" (f a b) c;
+
+  checkShiftRDivExp = n:
+    assertEq "${toString n} >> 5 == ${toString n} / 2 ^ 5"
+      (int.bitShiftR n 5) (int.div n (int.exp 2 5));
+
+  checkShiftLMulExp = n:
+    assertEq "${toString n} >> 6 == ${toString n} * 2 ^ 6"
+      (int.bitShiftL n 5) (int.mul n (int.exp 2 5));
+
+  testBit = it "checks bitwise operations" (lib.flatten [
+    (builtins.map checkShift shifts)
+    (builtins.map checkShiftRDivExp [
+      1
+      2
+      3
+      5
+      7
+      23
+      1623
+      238
+      34
+      348
+      2834
+      834
+      348
+    ])
+    (builtins.map checkShiftLMulExp [
+      1
+      2
+      3
+      5
+      7
+      23
+      384
+      3
+      2
+      5991
+      85109
+      38
+    ])
+  ]);
+
+  divisions = [
+    { a =  2; b =  1; c = 2; mod = 0;}
+    { a =  2; b =  2; c = 1; mod = 0;}
+    { a = 20; b = 10; c = 2; mod = 0;}
+    { a = 12; b =  5; c = 2; mod = 2;}
+    { a = 23; b =  4; c = 5; mod = 3;}
+  ];
+
+  checkDiv = n: { a, b, c, mod }: [
+    (assertEq "${n}: div result" (int.div a b) c)
+    (assertEq "${n}: mod result" (int.mod a b) mod)
+    (assertEq "${n}: divMod law" ((int.div a b) * b + (int.mod a b)) a)
+  ];
+
+  testDivMod = it "checks integer division and modulo"
+    (lib.flatten [
+      (builtins.map (checkDiv "+a / +b") divisions)
+      (builtins.map (fun.rl (checkDiv "-a / +b") (x: x // {
+        a = -x.a;
+        c = -x.c;
+        mod = -x.mod;
+      })) divisions)
+      (builtins.map (fun.rl (checkDiv "+a / -b") (x: x // {
+        b = -x.b;
+        c = -x.c;
+      })) divisions)
+      (builtins.map (fun.rl (checkDiv "-a / -b") (x: x // {
+        a = -x.a;
+        b = -x.b;
+        mod = -x.mod;
+      })) divisions)
+    ]);
+
+in
+  runTestsuite "nix.int" [
+    testBounds
+    testHex
+    testBasic
+    testExp
+    testBit
+    testDivMod
+  ]
diff --git a/users/sterni/nix/string/default.nix b/users/sterni/nix/string/default.nix
new file mode 100644
index 0000000000..782f86b7f7
--- /dev/null
+++ b/users/sterni/nix/string/default.nix
@@ -0,0 +1,71 @@
+{ depot, lib, ... }:
+
+let
+
+  inherit (depot.users.sterni.nix.char)
+    chr
+    ord
+    ;
+
+  inherit (depot.users.sterni.nix.flow)
+    cond
+    ;
+
+  take = n: s:
+    builtins.substring 0 n s;
+
+  drop = n: s:
+    builtins.substring n (builtins.stringLength s - n) s;
+
+  charAt = i: s:
+    let
+      r = builtins.substring i 1 s;
+    in if r == "" then null else r;
+
+  charIndex = char: s:
+    let
+      len = builtins.stringLength s;
+      go = i:
+        cond [
+          [ (i >= len) null ]
+          [ (charAt i s == char) i ]
+          [ true (go (i + 1)) ]
+        ];
+    in go 0;
+
+  toChars = lib.stringToCharacters;
+  fromChars = lib.concatStrings;
+
+  toBytes = str:
+    builtins.map ord (toChars str);
+
+  fromBytes = is: lib.concatMapStrings chr is;
+
+  pad = { left ? 0, right ? 0, char ? " " }: s:
+    let
+      leftS = fromChars (builtins.genList (_: char) left);
+      rightS = fromChars (builtins.genList (_: char) right);
+    in "${leftS}${s}${rightS}";
+
+  fit = { char ? " ", width, side ? "left" }: s:
+    let
+      diff = width - builtins.stringLength s;
+    in
+      if diff <= 0
+      then s
+      else pad { inherit char; "${side}" = diff; } s;
+
+in {
+  inherit
+    take
+    drop
+    charAt
+    charIndex
+    toBytes
+    fromBytes
+    toChars
+    fromChars
+    pad
+    fit
+    ;
+}
diff --git a/users/sterni/nix/string/tests/default.nix b/users/sterni/nix/string/tests/default.nix
new file mode 100644
index 0000000000..5769526734
--- /dev/null
+++ b/users/sterni/nix/string/tests/default.nix
@@ -0,0 +1,53 @@
+{ depot, ... }:
+
+let
+
+  inherit (depot.users.sterni.nix)
+    string
+    ;
+
+  inherit (depot.nix.runTestsuite)
+    it
+    assertEq
+    runTestsuite
+    ;
+
+  testTakeDrop = it "tests take and drop" [
+    (assertEq "take"
+      (string.take 5 "five and more")
+      "five ")
+    (assertEq "drop"
+      (string.drop 2 "coin")
+      "in")
+    (assertEq "take out of bounds"
+      (string.take 100 "foo")
+      "foo")
+    (assertEq "drop out of bounds"
+      (string.drop 42 "lol")
+      "")
+  ];
+
+  testIndexing = it "tests string indexing" [
+    (assertEq "normal charAt"
+      (string.charAt 3 "helo")
+      "o")
+    (assertEq "out of bounds charAt"
+      (string.charAt 5 "helo")
+      null)
+  ];
+
+  testFinding = it "tests finding in strings" [
+    (assertEq "normal charIndex"
+      (string.charIndex "d" "abcdefghijkl")
+      3)
+    (assertEq "charIndex no match"
+      (string.charIndex "w" "zZzZzzzZZZ")
+      null)
+  ];
+
+in
+  runTestsuite "nix.string" [
+    testTakeDrop
+    testIndexing
+    testFinding
+  ]
diff --git a/users/sterni/nix/url/default.nix b/users/sterni/nix/url/default.nix
new file mode 100644
index 0000000000..ce7ed9b834
--- /dev/null
+++ b/users/sterni/nix/url/default.nix
@@ -0,0 +1,42 @@
+{ depot, lib, ... }:
+
+let
+
+  inherit (depot.users.sterni.nix)
+    char
+    int
+    string
+    ;
+
+  reserved = c: builtins.elem c [
+    "!" "#" "$" "&" "'" "(" ")"
+    "*" "+" "," "/" ":" ";" "="
+    "?" "@" "[" "]"
+  ];
+
+  unreserved = c: char.asciiAlphaNum c
+    || builtins.elem c [ "-" "_" "." "~" ];
+
+  percentEncode = c:
+    if unreserved c
+    then c
+    else "%" + (string.fit {
+      width = 2;
+      char = "0";
+      side = "left";
+    } (int.toHex (char.ord c)));
+
+  encode = { leaveReserved ? false }: s:
+    let
+      chars = lib.stringToCharacters s;
+      tr = c:
+        if leaveReserved && reserved c
+        then c
+        else percentEncode c;
+    in lib.concatStrings (builtins.map tr chars);
+
+in {
+  inherit
+    encode
+    ;
+}
diff --git a/users/sterni/nix/url/tests/default.nix b/users/sterni/nix/url/tests/default.nix
new file mode 100644
index 0000000000..f58cf12a02
--- /dev/null
+++ b/users/sterni/nix/url/tests/default.nix
@@ -0,0 +1,49 @@
+{ depot, ... }:
+
+let
+
+  inherit (depot.nix.runTestsuite)
+    it
+    assertEq
+    runTestsuite
+    ;
+
+  inherit (depot.users.sterni.nix)
+    url
+    ;
+
+  checkEncoding = args: { left, right }:
+    assertEq "encode ${builtins.toJSON left} == ${builtins.toJSON right}"
+      (url.encode args left) right;
+
+  unreserved = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_~";
+
+  encodeExpected = [
+    { left = "Laguna Beach"; right = "Laguna%20Beach"; }
+    { left = "👾 Exterminate!"; right = "%F0%9F%91%BE%20Exterminate%21"; }
+    { left = unreserved; right = unreserved; }
+    {
+      left = "`!@#$%^&*()+={}[]:;'\\|<>,?/ \"";
+      right = "%60%21%40%23%24%25%5E%26%2A%28%29%2B%3D%7B%7D%5B%5D%3A%3B%27%5C%7C%3C%3E%2C%3F%2F%20%22";
+    }
+  ];
+
+  testEncode = it "checks url.encode"
+    (builtins.map (checkEncoding {}) encodeExpected);
+
+  testLeaveReserved = it "checks that leaveReserved is like id for valid URLs"
+    (builtins.map (x: checkEncoding { leaveReserved = true; } { left = x; right = x; }) [
+      "ftp://ftp.is.co.za/rfc/rfc1808.txt"
+      "http://www.ietf.org/rfc/rfc2396.txt"
+      "ldap://[2001:db8::7]/c=GB?objectClass?one"
+      "mailto:John.Doe@example.com"
+      "news:comp.infosystems.www.servers.unix"
+      "tel:+1-816-555-1212"
+      "telnet://192.0.2.16:80/"
+      "urn:oasis:names:specification:docbook:dtd:xml:4.1.2"
+    ]);
+in
+  runTestsuite "nix.url" [
+    testEncode
+    testLeaveReserved
+  ]