// STRYKE-UTILS — PURE-STRYKE UTILITY BELT

6 sublibraries · 149 composite functions · zero builtin re-wraps · no cdylib, no helper binary · use Utils pulls everything in one pass

Report GitHub Issues
// Color scheme

>_STRYKE-UTILS

Boundary helpers only. Cross-checked against %b. stryke-utils ships six sublibraries (String, List, Hash, Num, Time, Path) of higher-level helpers that aren't part of stryke core. The obvious composites — slugify, chunk, deep_merge, format_bytes, levenshtein, basename, common_prefix, ~70 more — have all landed in %b as builtins, so they're not duplicated here. What's left is the long tail: deep_merge_all, parse_duration, compound_ext, round_to_multiple, pad_center, escape_shell, mask_middle, unwrap, windows, set ops. 149 functions across 6 modules. Pure stryke: no [ffi] table, no cdylib, no helper binary — just .stk modules loaded by stryke's normal package resolver.

The 6 sublibraries

#ModuleFileFnsHighlights
1Utils::Stringlib/String.stk33ltrim/rtrim · pad_center · visible_width · count_occurrences · reverse_chars · squeeze · compact_whitespace · partition/rpartition · mask_middle · escape_shell · expand_tabs · unexpand_tabs · unwrap · ellipsize · truncate_words · nth_index · splitn · capitalize_first/uncapitalize · titleize · normalize_newlines · collapse_blank_lines · strip_quotes · repeat_to · ensure_prefix/ensure_suffix · prefix_lines · nth_line · count_lines_nonblank
2Utils::Listlib/List.stk20difference · intersection · union · symmetric_difference · is_disjoint · windows · cartesian (2-list pairs) · rle_encode/rle_decode · top_n/bottom_n (optional key fn) · count_where · transpose · running_sum/running_product · round_robin (n-list interleave) · deltas · rotate_left · index_of_max
3Utils::Hashlib/Hash.stk26deep_merge_all (n-ary wrapper) · deep_get/deep_set/deep_has (dot-path) · pluck_paths (multi-path read) · map_keys/map_values · all_hashes · rename_keys · flatten_keys/unflatten_keys (dot-path ⇄ nested) · invert_multi · deep_count · to_query_string
4Utils::Numlib/Num.stk26round_to_multiple/floor_to_multiple/ceil_to_multiple · ordinal (teen-aware) · percent_change · weighted_avg · percentile_of (midrank) · digit_sum · digit_count · pct_of · mean_abs_dev · midrange · clamp_to · mod_floor · next_power_of_two · divisors_of
5Utils::Timelib/Time.stk18parse_duration · ago (relative) · format_iso8601/format_date/format_time · timed · day_of_week · format_human/format_clock · add_duration/sub_duration · parse_iso8601 (round-trips format_iso8601) · truncate_to_hour · next_weekday/prev_weekday
6Utils::Pathlib/Path.stk26ext/compound_ext · without_ext/set_ext/splitext · strip_extension_all · join (variadic) · normalize (path) · is_absolute/is_relative/is_bare · relative · with_name/with_parent/add_suffix · strip_trailing_slash/ensure_trailing_slash · segments/depth · is_under

Color legend: green = module name · yellow = function count. Every sublibrary stands alone — no FFI, no required environment, no shared state between calls. Drop any single lib/*.stk file into another project as-is.

Install

# From a release:
s pkg install -g github.com/MenkeTechnologies/stryke-utils

# From a local checkout:
git clone https://github.com/MenkeTechnologies/stryke-utils
cd stryke-utils
s pkg install -g .              # installs into ~/.stryke/store/utils@<version>/

# Or via Makefile:
make install

No cargo step. No cdylib. The installed store directory contains only stryke.toml + lib/*.stk — no compiled artifacts, fully portable across platforms.

Quick Start

use Utils                                       # pulls all six sublibraries

# Strings — long-tail composites
Utils::String::ltrim("   hello")                # "hello"
Utils::String::pad_center("hi", 8, ".")         # "...hi..."
Utils::String::squeeze("aaabbc")                # "abc"
Utils::String::compact_whitespace("a   b\t c")  # "a b c"
Utils::String::partition("a/b/c", "/")          # ("a", "/", "b/c")
Utils::String::rpartition("a/b/c", "/")         # ("a/b", "/", "c")
Utils::String::mask_middle("4111232233334444", 4, 4)  # "4111********4444"
Utils::String::escape_shell("it's \$foo")       # "'it'\\''s \$foo'"
Utils::String::unwrap('"quoted"', '"')          # "quoted"

# Lists — set ops + sliding windows
Utils::List::difference([1,2,3,4], [2,4])       # [1,3]
Utils::List::intersection([1,2,3], [2,3,5])     # [2,3]
Utils::List::union([1,2], [2,3])                # [1,2,3]
Utils::List::windows([1,2,3,4], 2)              # [[1,2],[2,3],[3,4]]

# Hashes — variadic merge + dot-path access
my $cfg = Utils::Hash::deep_merge_all($defaults, $env, $runtime)
Utils::Hash::deep_get($cfg, "db.pool.max")      # nested read by dot path
Utils::Hash::deep_set($cfg, "db.pool.min", 5)   # autovivifying write
Utils::Hash::deep_has($cfg, "db.pool.min")      # missing-vs-undef differentiator

# Numbers
Utils::Num::ordinal(21)                         # "21st"
Utils::Num::round_to_multiple(13, 5)            # 15

# Time — parsing + relative phrasing + ISO formatting
Utils::Time::parse_duration("1h30m")            # 5400
Utils::Time::ago(time() - 90)                   # "1 minute ago"
Utils::Time::format_iso8601()                   # "2026-06-10T14:23:05Z"

# Paths — string-only path arithmetic
Utils::Path::compound_ext("archive.tar.gz")     # "tar.gz"
Utils::Path::set_ext("a/b.csv", "parquet")      # "a/b.parquet"
Utils::Path::normalize("/a/b/../c/./d")         # "/a/c/d"
Utils::Path::relative("/a/x", "/a/b/c")         # "../../x"
Utils::Path::join("/a", "b/", "/c")             # "/a/b/c"

# Everything else — slugify, chunk, uniq, deep_merge, format_bytes,
# levenshtein, basename, common_prefix — call the stryke builtin directly.

Pull only what you need with explicit sub-imports: use Utils::String, use Utils::Path, etc. The umbrella use Utils is just for convenience.

Full reference — Utils::String

33 string composites. Every entry is a pure function: it reads its arguments and returns a new value (no in-place mutation). use Utils::String for this module alone, or use Utils for everything.

FunctionBehaviourExample
ltrim($s)Strip leading whitespace only (bidirectional trim is a builtin).ltrim(" hi")"hi"
rtrim($s)Strip trailing whitespace only.rtrim("hi ")"hi"
pad_center($s, $width, $ch = " ")Pad to $width centred; the extra char goes on the right. Returns $s unchanged when already wide enough.pad_center("hi", 8, ".")"...hi..."
visible_width($s)length after stripping ANSI escapes.visible_width("\e[31mhi\e[0m")2
count_occurrences($s, $needle)Non-overlapping occurrence count; 0 for an empty needle.count_occurrences("aaaa", "aa")2
reverse_chars($s)Reverse by code point (not graphemes).reverse_chars("abc")"cba"
squeeze($s)Collapse runs of the same character to one, like tr -s.squeeze("aaabbc")"abc"
compact_whitespace($s)Collapse every whitespace run to a single space and trim ends — the normalized form for comparison or hashing.compact_whitespace("a b\t c")"a b c"
partition($s, $sep)Split at the FIRST $sep, returning ($before, $sep, $after); ($s, "", "") when absent.partition("a/b/c", "/")("a", "/", "b/c")
rpartition($s, $sep)Split at the LAST $sep; ("", "", $s) when absent.rpartition("a/b/c", "/")("a/b", "/", "c")
mask_middle($s, $head, $tail, $ch = "*")Keep first $head + last $tail chars; replace the middle with $ch. Unchanged if shorter than $head + $tail.mask_middle("4111222233334444", 4, 4)"4111********4444"
escape_shell($s)Single-quote for a POSIX shell argument; embedded quotes become '\''.escape_shell("it's")"'it'\''s'"
expand_tabs($s, $width = 4)Replace each tab with $width spaces (not tab-stop aligned).expand_tabs("x\ty", 2)"x y"
unexpand_tabs($s, $width = 4)Convert leading $width-space runs back to tabs, per line; only leading whitespace is touched.unexpand_tabs(" x", 4)"\t\tx"
unwrap($s, $prefix, $suffix = undef)Strip $prefix/$suffix only if BOTH match; $suffix defaults to $prefix.unwrap('"quoted"', '"')"quoted"
ellipsize($s, $max, $ellipsis = "…")Truncate to at most $max characters; the ellipsis counts toward the cap.ellipsize("hello world", 8)"hello w…"
truncate_words($s, $n, $ellipsis = "…")Keep the first $n whitespace-delimited words, appending the ellipsis when words were dropped.truncate_words("a b c d", 2)"a b…"
nth_index($s, $needle, $n)Index of the $n-th (1-based) non-overlapping occurrence, or -1.nth_index("a.b.c", ".", 2)3
splitn($s, $sep, $n)Split into at most $n pieces; the last piece keeps remaining separators verbatim.splitn("a:b:c:d", ":", 2)("a", "b:c:d")
capitalize_first($s)Uppercase char 0 only; the rest untouched.capitalize_first("hello")"Hello"
uncapitalize($s)Lowercase char 0 only.uncapitalize("Hello")"hello"
titleize($s)Capitalize the first letter of every word, lowercasing the rest; collapses internal whitespace.titleize("hELLO wORLD")"Hello World"
normalize_newlines($s)CRLF and lone CR both become LF — run before any text comparison or hash.normalize_newlines("a\r\nb")"a\nb"
collapse_blank_lines($s)Collapse runs of 2+ blank lines (whitespace-only counts as blank) to one; normalizes newlines first.collapse_blank_lines("a\n\n\nb")"a\n\nb"
strip_quotes($s)Strip one layer of matching "…" or '…', auto-detecting which; unmatched input unchanged.strip_quotes("'x'")"x"
repeat_to($unit, $width)Repeat $unit to exactly $width characters, truncating the last copy.repeat_to("ab", 5)"ababa"
remove_prefix($s, $prefix)One-sided strip of $prefix if present.remove_prefix("unwrap", "un")"wrap"
remove_suffix($s, $suffix)One-sided strip of $suffix if present.remove_suffix("file.bak", ".bak")"file"
ensure_prefix($s, $prefix)Add $prefix only when absent (idempotent).ensure_prefix("file", "/")"/file"
ensure_suffix($s, $suffix)Add $suffix only when absent (idempotent).ensure_suffix("page", "/")"page/"
prefix_lines($s, $prefix)Prepend $prefix to every line; newlines kept verbatim.prefix_lines("a\nb", "> ")"> a\n> b"
nth_line($s, $n)The $n-th (1-based) line without its newline; negative $n counts from the end; undef when out of range.nth_line("a\nb\nc", 2)"b"
count_lines_nonblank($s)Count lines with at least one non-whitespace character.count_lines_nonblank("a\n\nb\n")2

Full reference — Utils::List

20 arrayref composites — set operations, windowing, run-length, ranking, and running aggregates. Lists are passed and returned as arrayrefs.

FunctionBehaviourExample
difference($a, $b)Items in $a not in $b (string equality, preserves $a's order).difference([1,2,3,4], [2,4])[1,3]
intersection($a, $b)Items in BOTH, in $a's order, deduped.intersection([1,2,3], [2,3,5])[2,3]
union($a, $b)Items in EITHER, deduped, $a's first.union([1,2], [2,3])[1,2,3]
symmetric_difference($a, $b)Items in exactly one list — (A−B)∪(B−A), deduped.symmetric_difference([1,2,3], [2,3,4])[1,4]
is_disjoint($a, $b)True when the two lists share no element.is_disjoint([1,2,3], [4,5])1
windows($items, $size)Sliding windows of length $size, step 1; dies on $size < 1.windows([1,2,3,4], 2)[[1,2],[2,3],[3,4]]
chunk($items, $size)Non-overlapping chunks of length $size (last is short if it does not divide evenly).chunk([1,2,3,4,5], 2)[[1,2],[3,4],[5]]
cartesian($a, $b)Cartesian pairs, $a-major order (the cheap 2-list case).cartesian([1,2], ["x"])[[1,"x"],[2,"x"]]
rle_encode($items)Run-length encode consecutive equal items.rle_encode(["a","a","b"])[["a",2],["b",1]]
rle_decode($pairs)Inverse of rle_encode.rle_decode([["a",2],["b",1]])["a","a","b"]
top_n($items, $n, $key)Largest $n items descending; numeric compare by default, optional $key scorer.top_n([3,1,4,1,5], 2)[5,4]
bottom_n($items, $n, $key)Smallest $n items ascending; same $key contract as top_n.bottom_n([3,1,4,1,5], 2)[1,1]
count_where($items, $pred)How many items satisfy $pred (counting companion to grep).count_where([1,2,3,4], fn ($x) { $x > 2 })2
transpose($rows)Transpose a rectangular matrix; dies on ragged rows.transpose([[1,2,3],[4,5,6]])[[1,4],[2,5],[3,6]]
running_sum($nums)Inclusive prefix sums (scan companion to sum).running_sum([1,2,3,4])[1,3,6,10]
running_product($nums)Inclusive prefix products.running_product([1,2,3,4])[1,2,6,24]
round_robin(@lists)Interleave N arrayrefs by index, tolerating ragged lengths.round_robin([1,2,3],[4,5],[6])[1,4,6,2,5,3]
deltas($nums)Adjacent differences (discrete derivative); N items yield N−1 deltas.deltas([1,4,9,16])[3,5,7]
rotate_left($items, $n)Cyclic left shift by $n (taken mod length; negative rotates right).rotate_left([1,2,3,4,5], 2)[3,4,5,1,2]
index_of_max($items, $key)Index of the maximum (first on ties); -1 for empty; optional $key scorer.index_of_max([3,9,2,9])1

Full reference — Utils::Hash

24 public hashref composites plus two private helpers (_vstr, _urlenc). Variadic merge, dot-path access, key reshaping, predicate selection, diffing, and query-string serialization.

FunctionBehaviourExample
deep_merge_all(@hashes)N-ary deep merge (later overrides earlier); folds the binary deep_merge builtin.deep_merge_all($a, $b, $c)
deep_get($h, $path)Read a nested value by dot path; undef on any missing segment. Pure read, no autoviv.deep_get($cfg, "db.pool.max")
deep_set($h, $path, $value)Set a nested value by dot path, autovivifying intermediates; returns the modified $h.deep_set($cfg, "db.pool.min", 5)
deep_has($h, $path)True if the dot path resolves to an existing key (even when the value is undef).deep_has($cfg, "db.pool.min")
map_keys($h, $f)Transform every key via $f->($k, $v); values unchanged.map_keys($h, fn ($k, $v) { uc($k) })
map_values($h, $f)Transform every value via $f->($v, $k); keys unchanged.map_values($h, fn ($v, $k) { $v * 2 })
all_hashes($h)True if every value is itself a hashref.all_hashes({a => {}, b => {}})1
rename_keys($h, $mapping)Rename keys per $mapping (old → new); unmapped keys pass through.rename_keys($h, {id => "uid"})
flatten_keys($h, $prefix)Flatten nested hashes into one level of dot-path keys; empty nested hashes kept as leaves.flatten_keys({a => {b => 1}}){"a.b" => 1}
unflatten_keys($h)Expand dot-path keys back into nested hashes.unflatten_keys({"a.b" => 1}){a => {b => 1}}
deep_delete($h, $path)Delete the leaf at dot path, mutating $h; no-op if any segment is missing.deep_delete($h, "a.b")
defaults($h, $def)Copy of $h with $def keys added only where absent.defaults({a => 1}, {a => 9, b => 2}){a => 1, b => 2}
deep_keys($h)Sorted list of every leaf dot path.deep_keys({a => {b => 1}, c => 2})["a.b","c"]
deep_values($h)Leaf values in deep_keys order.deep_values({a => {b => 1}})
count_values($h)Frequency of each stringified value across the hash.count_values({a => 1, b => 1, c => 2}){1 => 2, 2 => 1}
map_entries($h, $f)Map each (k, v) to a new (k, v) via $f.map_entries({a => 1}, fn ($k, $v) { (uc($k), $v*10) }){A => 10}
merge_with($f, $a, $b)Merge $a and $b; on shared keys the value is $f->(a_val, b_val).merge_with(fn ($x, $y) { $x+$y }, {a => 1}, {a => 2}){a => 3}
pick_by($h, $pred)Keep pairs where $pred->($k, $v) is true.pick_by({a => 1, b => 2}, fn ($k, $v) { $v > 1 }){b => 2}
omit_by($h, $pred)Drop pairs where $pred is true (inverse of pick_by).omit_by({a => 1, b => 2}, fn ($k, $v) { $v > 1 }){a => 1}
hash_diff($a, $b)Shallow diff by stringified value: {changed, added, removed}.hash_diff($old, $new)
invert_multi($h)Invert a non-unique-valued hash: each value maps to a sorted arrayref of keys.invert_multi({a => 1, b => 1, c => 2}){1 => ["a","b"], 2 => ["c"]}
deep_count($h)Count leaf values in a nested hash (size of flatten_keys).deep_count({a => {b => 1, c => 2}, d => 3})3
pluck_paths($h, $paths)Project a set of dot paths into a flat result hash keyed by the path string.pluck_paths($h, ["a.b", "d", "x.y"])
to_query_string($h)Render a flat hash as a URL query string, keys sorted, key+value percent-encoded (RFC-3986).to_query_string({b => 2, a => "x y"})"a=x%20y&b=2"

Full reference — Utils::Num

26 numeric composites: multiple-rounding, ordinals, change/aggregate stats, digit math, conversions, and bounds/moduli. Several die on degenerate input (zero step, zero whole, empty list) rather than returning a silently-wrong value.

FunctionBehaviourExample
round_to_multiple($x, $step)Round to the nearest multiple of $step; dies on a zero step.round_to_multiple(13, 5)15
floor_to_multiple($x, $step)Round down toward −∞ to a multiple of $step.floor_to_multiple(13, 5)10
ceil_to_multiple($x, $step)Round up toward +∞ to a multiple of $step.ceil_to_multiple(11, 5)15
ordinal($n)Ordinal suffix, teen-aware (11th/12th/13th vs 21st/22nd/23rd).ordinal(21)"21st"
percent_change($old, $new)Percent change from $old to $new; dies on a zero old value.percent_change(50, 75)50
weighted_avg($values, $weights)Weighted average by parallel weights; dies on length mismatch, empty input, or zero total weight.weighted_avg([1,10], [3,1])3.25
percentile_of($sample, $x)Percentile rank of $x within the sample (midrank for ties), 0-100.percentile_of([1,2,3,4], 3)62.5
digit_sum($n)Sum of the decimal digits of |int($n)|.digit_sum(1234)10
digit_count($n)Count of decimal digits of |int($n)|.digit_count(0)1
digital_root($n)Repeatedly sum digits until one remains.digital_root(9875)2
pct_of($part, $whole)What percent $part is of $whole; dies on a zero whole.pct_of(25, 200)12.5
mean_abs_dev($nums)Mean absolute deviation about the mean; dies on empty input.mean_abs_dev([1,2,3,4])1
is_close($a, $b, $tol = 1e-9, $rel = 1e-9)Float-safe equality: within $tol absolute OR $rel relative to the larger magnitude.is_close(0.1 + 0.2, 0.3)1
to_radians($deg)Degrees → radians.to_radians(180)3.14159…
to_degrees($rad)Radians → degrees.to_degrees(3.14159265…)180
round_sig($x, $sig = 3)Round to $sig significant figures; dies on $sig < 1.round_sig(3.14159, 3)3.14
clamp01($x)Clamp into [0, 1].clamp01(1.5)1
remap($x, $in_lo, $in_hi, $out_lo, $out_hi)Linear remap between ranges (extrapolates outside); dies on a degenerate input range.remap(5, 0, 10, 0, 100)50
wrap($x, $min, $max)Wrap into the half-open [$min, $max) (modular, for angles/cyclic indices); dies on an empty range.wrap(370, 0, 360)10
gcd($a, $b)Greatest common divisor (Euclidean, on absolute values).gcd(12, 18)6
round_half_even($x, $digits = 0)Banker's rounding (round half to even) — the IEEE-754 / finance default.round_half_even(2.5)2
clamp_to($x, $lo, $hi)Clamp into [$lo, $hi] (arbitrary-range clamp01); dies on $lo > $hi.clamp_to(12, 0, 10)10
mod_floor($x, $m)Floored modulo carrying the sign of the DIVISOR; dies on a zero modulus.mod_floor(-1, 3)2
next_power_of_two($n)Smallest power of two ≥ $n; dies on $n < 1.next_power_of_two(17)32
midrange($nums)Midpoint of the data's extent, (min+max)/2; dies on empty input.midrange([1, 2, 9])5
divisors_of($n)Sorted list of every positive divisor of |int($n)|; dies on 0.divisors_of(12)[1,2,3,4,6,12]

Full reference — Utils::Time

17 public time/duration composites plus the private _days_from_civil helper. All calendar functions operate in UTC. parse_iso8601 uses Howard Hinnant's days-from-civil algorithm and round-trips format_iso8601 exactly.

FunctionBehaviourExample
parse_duration($s)Duration string → seconds; accepts d/h/m/s/ms, whitespace ignored; dies on unknown units.parse_duration("1h30m")5400
ago($epoch, $now = undef)Relative phrasing; past is "X ago", future is "in X"; rolls to the nearest unit.ago(time() - 90)"1 minute ago"
format_iso8601($epoch = undef)UTC ISO-8601 string.format_iso8601(0)"1970-01-01T00:00:00Z"
format_date($epoch = undef)YYYY-MM-DD (UTC).format_date(0)"1970-01-01"
format_time($epoch = undef)HH:MM:SS (UTC).format_time(0)"00:00:00"
timed($code)Run a coderef and return [result, elapsed_seconds].timed(fn { work() })
day_of_week($epoch = undef)Weekday name (UTC).day_of_week(0)"Thursday"
format_human($epoch = undef)Long human date (UTC).format_human(0)"Thursday, 1 January 1970"
format_clock($epoch = undef)12-hour clock (UTC).format_clock(0)"12:00:00 AM"
add_duration($epoch, $dur)Add a duration string to an epoch.add_duration($t, "1h30m")
sub_duration($epoch, $dur)Subtract a duration string from an epoch.sub_duration($t, "1h30m")
parse_iso8601($s)Parse a UTC ISO-8601 timestamp into an epoch (T- or space-separated, trailing Z optional); inverse of format_iso8601; dies on malformed input.parse_iso8601("1970-01-01T00:00:00Z")0
quarter($month)Calendar quarter (1-4) for a 1-12 month; dies out of range.quarter(4)2
is_same_day($a, $b)True if two epochs fall on the same UTC calendar day.is_same_day(0, 3600)1
truncate_to_hour($epoch = undef)Floor an epoch to the start of its UTC hour (exact, DST-free).truncate_to_hour(3661)3600
next_weekday($epoch, $target)Epoch of the next weekday $target (0=Sun..6=Sat) strictly after $epoch; dies out of range.next_weekday($t, 1)
prev_weekday($epoch, $target)Mirror of next_weekday, stepping back.prev_weekday($t, 5)

Full reference — Utils::Path

26 path-string composites. This module only manipulates the path string — it never touches the disk. Utils::Path::join and Utils::Path::normalize are the two intentional name overlaps with builtins (path-join vs array-join; path-normalize vs vector-normalize).

FunctionBehaviourExample
ext($path)Last extension without the dot, or "".ext("x.tar.gz")"gz"
compound_ext($path)Compound extension (each segment alphanumeric and ≤ 4 chars).compound_ext("archive.tar.gz")"tar.gz"
without_ext($path)Drop the single-segment extension.without_ext("x/y.parquet")"x/y"
set_ext($path, $new)Replace the extension; leading "." in $new tolerated; adds it if absent.set_ext("a/b.csv", "parquet")"a/b.parquet"
splitext($path)Split into (stem, ext), ext without dot.splitext("/a/b.csv")("/a/b", "csv")
join(@parts)Variadic, slash-aware path join; strips trailing slashes, drops empties, preserves a leading "/".join("/a", "b/", "/c")"/a/b/c"
normalize($path)Resolve . and .. lexically; walking above root collapses to "/" (or "." relative).normalize("/a/b/../c/./d")"/a/c/d"
is_absolute($path)True if it starts with "/".is_absolute("/a")1
is_bare($path)True if it has no "/" at all.is_bare("file.txt")1
relative($target, $from)Path of $target relative to $from (both normalized first); "." if equal.relative("/a/x", "/a/b/c")"../../x"
with_name($path, $name)Replace the final segment (verbatim, extension and all).with_name("a/b.txt", "c.md")"a/c.md"
add_suffix($path, $suffix)Insert $suffix before the single-segment extension.add_suffix("a/b.txt", "-bak")"a/b-bak.txt"
strip_trailing_slash($path)Remove trailing slashes (never reduces "/" itself to "").strip_trailing_slash("a/b/")"a/b"
ensure_trailing_slash($path)Guarantee exactly one trailing slash.ensure_trailing_slash("a/b")"a/b/"
segments($path)Non-empty segments (leading "/" dropped).segments("/a/b/c")["a","b","c"]
depth($path)Segment count.depth("/a/b/c")3
is_under($path, $base)True if $path is at or below $base after normalizing both (lexical only).is_under("/a/b/c", "/a/b")1
is_hidden($path)True if the final component begins with a dot.is_hidden("/a/.env")1
expand_user($path)Expand a leading "~" to $ENV{HOME}; non-tilde paths unchanged.expand_user("~/x")"$HOME/x"
sibling($path, $name)Replace the final component with $name.sibling("/a/b", "c")"/a/c"
ancestors($path)Every ancestor directory, nearest first.ancestors("/a/b/c")["/a/b","/a","/"]
with_stem($path, $stem)Replace the stem (basename without extension), keeping the extension.with_stem("/a/b.txt", "c")"/a/c.txt"
common_ancestor($paths)Longest common ancestor directory, segment-wise.common_ancestor(["/a/b/x", "/a/b/y", "/a/c"])"/a"
strip_extension_all($path)Strip every extension from the final component (a leading-dot basename is unchanged).strip_extension_all("a/b.tar.gz")"a/b"
is_relative($path)Negation of is_absolute.is_relative("a/b")1
with_parent($path, $dir)Replace the directory, keeping the basename; trailing slash on $dir dropped.with_parent("a/b/c.txt", "x/y")"x/y/c.txt"

What's NOT in Here

By design — these are stryke builtins, so we don't re-wrap them. Every entry below was present in stryke-utils at one point and got deleted when the corresponding builtin landed in core. If a function in this library can be replaced with one builtin call, it's a bug.

CategoryBuiltins (call directly)
String case / slugtrim · slugify · snake_case · kebab_case · camel_case · pascal_case · title_case · swap_case · indent · dedent · rot13
String predicates / distancecontains · starts_with · ends_with · is_blank · is_palindrome · levenshtein · hamming · dice_coefficient · find_all_indices · common_prefix · common_suffix
String padding / shapingpad_left · pad_right · truncate · strip_ansi · word_wrap
Listchunk · compact · uniq · uniq_by · flatten · group_by · count_by · index_by · partition · pluck · sum · mean · median · min_by · max_by · sort_by · zip · take · drop · range
Hashdeep_merge · pick · omit · invert · filter · from_pairs · to_pairs · is_empty
Numclamp · between · lerp · round_to · format_number · format_bytes · format_percent · gcd · lcm · sign · is_even · is_odd
Timenow_ms · now_us · format_duration · elapsed
Pathbasename · dirname · common_prefix
Core (always was)uc · lc · length · sprintf · substr · index · rindex · split · push/pop/shift/unshift/splice/sort/reverse/map/grep/join · keys/values/exists/defined/scalar/wantarray · abs/int/sqrt/sin/cos/log/exp · time/localtime/gmtime/sleep · mkdir/unlink/rmdir/opendir/readdir · -e/-d/-f/-s · to_json/from_json · the .. range and x repetition operators

CLI

bin/utils.stk is a thin dispatcher over the surviving lib fns — useful for shell pipelines without writing a full .stk file. Every subcommand routes to one function in lib/*.stk. The full subcommand surface (run s bin/utils.stk help for the in-tool usage):

# String
s bin/utils.stk pad-center "hi" 8 .            # ...hi...
s bin/utils.stk squeeze "aaabbc"               # abc
s bin/utils.stk compact-ws "a   b\t c"         # a b c
s bin/utils.stk rpartition "a/b/c" /           # a/b  /  c  (tab-separated)
s bin/utils.stk mask 4111222233334444 4 4      # 4111********4444
s bin/utils.stk escape-shell "it's \$foo"
s bin/utils.stk expand-tabs "x\ty" 2           # x  y
s bin/utils.stk unwrap '"quoted"' '"'          # quoted
s bin/utils.stk visible-width $'\e[31mhi\e[0m' # 2

# Num
s bin/utils.stk ordinal 21                     # 21st
s bin/utils.stk round-multiple 13 5            # 15

# Time
s bin/utils.stk parse-duration 1h30m           # 5400
s bin/utils.stk ago $((now - 90))              # 1 minute ago
s bin/utils.stk iso  [EPOCH]                    # 2026-06-10T14:23:05Z
s bin/utils.stk date [EPOCH]                    # 2026-06-10
s bin/utils.stk time [EPOCH]                    # 14:23:05

# Path
s bin/utils.stk ext path/file.tar.gz           # gz
s bin/utils.stk compound-ext archive.tar.gz    # tar.gz
s bin/utils.stk without-ext path/file.tar.gz   # path/file.tar
s bin/utils.stk set-ext a/b.csv parquet        # a/b.parquet
s bin/utils.stk normalize a/./b/../c           # a/c
s bin/utils.stk relative /a/x /a/b/c           # ../../x
s bin/utils.stk path-join /a b/ /c             # /a/b/c

# Meta
s bin/utils.stk version
s bin/utils.stk help

# For builtins (slugify, format_bytes, format_duration, …) call stryke directly:
s -e 'print slugify($ARGV[0])' -- "Hello, World!"

The convenience wrappers that used to live here (slugify, bytes, duration, basename, …) were dropped when their targets became one-liner stryke builtins. An unknown subcommand exits non-zero with utils: unknown subcommand.

Examples

Four runnable, self-contained programs in examples/ — no services, no temp files. Run any with s examples/<name>.stk.

FileWhat it shows
discover.stkMinimum-viable tour — one call from each of the six sublibraries plus the matching builtins that graduated out of this package, so you can see what is still here and what moved to core.
word_frequency.stkWord-frequency pipeline over a string. Most steps (trim, count_by, format_number, format_percent, pad_right) are builtins now; the umbrella use Utils stays loaded as the discovery surface.
config_merge.stkLayered config (defaults → env → runtime) via Utils::Hash::deep_merge_all + deep_get.
deploy_digest.stkEnd-to-end deploy-log digest exercising the package's later additions: ISO-8601 parsing (Time), quote-strip + fills + ellipsize (String), error-rate and dispersion stats (Num), slowest-N ranking (List), and an output path derived from the input (Path). The same pipeline is pinned in t/test_utils.stk.

Tests

s test t/                       # assertions across every public function
make check-counts               # verify every doc count matches the source

t/test_utils.stk covers every public function with at least one round-trip / boundary assertion and exits TAP-style via test_run. scripts/check-counts.sh derives the per-sublib counts, the total, the assertion count, and the example count straight from lib/, t/, and examples/, then fails if any number quoted in README.md or docs/*.html disagrees — so the hand-written doc numbers can't silently rot. It runs in CI on every push.

Layout

stryke-utils/
├── stryke.toml                # pure-stryke package manifest (no [ffi])
├── Makefile                   # test / install / clean
├── LICENSE                    # MIT
├── lib/
│   ├── Utils.stk              # `use Utils` — pulls all six sublibs
│   ├── String.stk             # `use Utils::String` — 33 fns
│   ├── List.stk               # `use Utils::List`   — 20 fns
│   ├── Hash.stk               # `use Utils::Hash`   — 26 fns
│   ├── Num.stk                # `use Utils::Num`    — 26 fns
│   ├── Time.stk               # `use Utils::Time`   — 18 fns
│   └── Path.stk               # `use Utils::Path`   — 26 fns
├── bin/
│   └── utils.stk              # CLI front-end
├── t/
│   └── test_utils.stk         # all-surface assertions
├── examples/
│   ├── discover.stk           # one call per sublib
│   ├── word_frequency.stk     # String + List + Hash + Num pipeline
│   ├── config_merge.stk       # layered config via deep_merge_all
│   └── deploy_digest.stk      # Time + String + Num + List + Path pipeline
├── tests/                     # shell gate scripts (CI lints)
└── docs/                      # this site (GitHub Pages)

Sibling packages

Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo: