// STRYKE-UTILS — ENGINEERING REPORT

Six sublibs · 149 composite fns · zero builtin re-wraps · pure stryke (no [ffi], no cdylib) · use Utils pulls all

>_EXECUTIVE SUMMARY

stryke-utils is the higher-level standard library for stryke — the layer sitting above builtins and below domain packages like stryke-arrow / stryke-postgres. Six sublibraries cover the composites every application code path eventually needs: case conversion, slugification, distance metrics, set operations on lists, deep merge and dot-path access on hashes, human-readable byte/duration/percent formatting, and lexical path arithmetic. The package is intentionally pure stryke — no [ffi] table, no cdylib, no helper binary — so the install is a copy of seven .stk files and works identically on every target stryke runs on.

The single contract that drives every design decision is the no-builtin-rewrap rule: if a function in this library can be replaced with one builtin call (uc, length, sort, keys, time, mkdir, ...) it's a bug. That keeps the surface focused on real work and prevents the library from accumulating the indistinguishable sub-builtins (str_upper, arr_length, ...) that bloat lodash/underscore-class libraries in other ecosystems.

6
sublibraries
149
composite fns
0
cdylib / helper binaries
316
assertions in t/
4
runnable examples
22
CI gate scripts

~SUBLIBRARY BOUNDARIES

Each sublibrary owns one shape of input. The split is by primitive type (string / list / hash / number / time / path) rather than by intent (formatting / parsing / validation / ...) so users can find a function from its input type alone.

SublibOwns inputs of typeDoesn't own
Utils::StringScalar strings — long-tail composites (asymmetric trim, centred padding, squeeze, whitespace compaction, right-partition, mask, shell-quote, tab-expand, unwrap, visible-width, char-aware ellipsize, word truncation, nth-occurrence index, bounded split, first-letter case, titleize, newline normalization, blank-line collapse, quote stripping, fill-to-width).Case/slug/distance/predicates — absorbed into stryke core (slugify, trim, levenshtein, strip_ansi, pad_left, pad_right, swap_case, truncate, indent, dedent, …). Call them directly.
Utils::ListArrayrefs — the set-op family (difference, intersection, union), sliding windows, 2-list cartesian pairs, rle_encode/rle_decode, and top_n/bottom_n ranking.Chunking, deduping, grouping, statistics, projections — chunk, uniq, group_by, count_by, partition, sort_by, mean, median, zip, etc. are all builtins now.
Utils::HashHashrefs — variadic merge (deep_merge_all), dot-path access (deep_get/deep_set/deep_has) and reshaping (rename_keys, flatten_keys/unflatten_keys), plus map_keys/map_values/all_hashes.Binary deep_merge, pick, omit, invert, filter, to_pairs/from_pairs, is_empty — all builtins. Hash iteration (keys/each) always was.
Utils::NumNumeric scalars — the long tail: round_to_multiple, teen-aware ordinal, percent_change, weighted_avg, midrank percentile_of, digit_sum/digit_count, pct_of, mean_abs_dev.Clamping, rounding, formatting (format_number/format_bytes/format_percent), sign, parity, gcd/lcm — all in core.
Utils::TimeEpoch ints + duration strings — parse_duration, relative ago, ISO-8601 family (incl. parse_iso8601 via the civil-days algorithm), calendar names (day_of_week, format_human, format_clock), duration arithmetic (add_duration/sub_duration), timed.now_ms/now_us, format_duration, elapsed, weekday_name/month_name — builtins. Non-UTC timezone arithmetic remains out of scope.
Utils::PathFilesystem path strings — extension family (ext, compound_ext, without_ext, set_ext, splitext), variadic join, path-aware normalize, relative, is_absolute/is_bare, filename surgery (with_name, add_suffix), trailing-slash control (strip_trailing_slash, ensure_trailing_slash), segmentation (segments, depth), lexical containment (is_under).basename, dirname, common_prefix — builtins. FS I/O — mkdir, unlink, opendir, -e/-d/-f/-s — always were. We never touch the disk.

=FUNCTION INVENTORY

The complete public surface per sublibrary — 149 functions. Names map one-to-one to fn Utils::<Sub>::<name> in lib/<Sub>.stk. The per-function signatures, behaviour notes, and examples live on the Docs page; this is the roster.

SublibCountFunctions
Utils::String33ltrim · 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 · remove_prefix · remove_suffix · ensure_prefix · ensure_suffix · prefix_lines · nth_line · count_lines_nonblank
Utils::List20difference · intersection · union · symmetric_difference · is_disjoint · windows · chunk · cartesian · rle_encode · rle_decode · top_n · bottom_n · count_where · transpose · running_sum · running_product · round_robin · deltas · rotate_left · index_of_max
Utils::Hash26deep_merge_all · deep_get · deep_set · deep_has · map_keys · map_values · all_hashes · rename_keys · flatten_keys · unflatten_keys · deep_delete · defaults · deep_keys · deep_values · count_values · map_entries · merge_with · pick_by · omit_by · hash_diff · invert_multi · deep_count · pluck_paths · to_query_string · _vstr · _urlenc (last two private)
Utils::Num26round_to_multiple · floor_to_multiple · ceil_to_multiple · ordinal · percent_change · weighted_avg · percentile_of · digit_sum · digit_count · digital_root · pct_of · mean_abs_dev · is_close · to_radians · to_degrees · round_sig · clamp01 · remap · wrap · gcd · round_half_even · clamp_to · mod_floor · next_power_of_two · midrange · divisors_of
Utils::Time18parse_duration · ago · format_iso8601 · format_date · format_time · timed · day_of_week · format_human · format_clock · add_duration · sub_duration · parse_iso8601 · quarter · is_same_day · truncate_to_hour · next_weekday · prev_weekday · _days_from_civil (private)
Utils::Path26ext · compound_ext · without_ext · set_ext · splitext · join · normalize · is_absolute · is_bare · relative · with_name · add_suffix · strip_trailing_slash · ensure_trailing_slash · segments · depth · is_under · is_hidden · expand_user · sibling · ancestors · with_stem · common_ancestor · strip_extension_all · is_relative · with_parent

The per-sublib counts come straight from grep -c '^fn Utils::<Sub>' lib/<Sub>.stk — the same derivation scripts/check-counts.sh runs in CI, so the roster above and the doc totals can never drift from source. Private helpers (_vstr, _urlenc, _days_from_civil) are counted because they are real fn definitions in the module file.


+ALGORITHM NOTES

A few functions carry non-obvious implementation choices worth pinning. All are sourced from the function bodies in lib/*.stk.

FunctionChoice
Time::parse_iso8601Converts the civil date to a day count with Howard Hinnant's days-from-civil algorithm (exact, no lookup table), then adds the time-of-day seconds. Round-trips format_iso8601 exactly; dies on a malformed string.
Num::round_half_evenBanker's rounding (round half to even) — the IEEE-754 / finance default — unlike core round which rounds halves away from zero. A value within ~1e-9 of a half is treated as an exact half.
Num::mod_floorFloored modulo: the result carries the sign of the DIVISOR, so for a positive modulus it always lands in [0, m) — what cyclic indices and clock arithmetic want, unlike the % operator.
Num::is_closeCombines an absolute tolerance and a relative tolerance (against the larger magnitude) so it works near zero and at large magnitudes — the float-safe replacement for ==.
Num::divisors_ofWalks divisors up to √n, collecting the small factor and its co-factor each step, then concatenates — O(√n) rather than O(n).
Hash::to_query_stringKeys sorted for a stable, reproducible string; both key and value percent-encoded keeping the RFC-3986 unreserved set literal; undef values render as an empty value.
Path::compound_extTreats a suffix as compound only when each trailing segment is purely alphanumeric and ≤ 4 chars (so tar.gz is compound but config.backup is not).
List::rotate_left$n is reduced modulo the length with a double-mod so any integer — including negative (rotate right) — is valid.

$NO-BUILTIN-REWRAP CONTRACT

The single rule that shapes every decision: if a function here can be replaced with one builtin call, it's a bug. Enforced mechanically — every public fn is grepped against %b at build time; collisions fail the gate. The two intentional name overlaps (Utils::Path::join, Utils::Path::normalize) are whitelisted because the lib semantics are genuinely distinct from the builtin's (path join vs array join, path normalize vs vector normalize).

CategoryBuiltins (formerly in this package, now absorbed)
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 (binary) · 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/delete/each/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 · .. range · x repetition

The "absorbed" rows reflect actual deletions from this package — ~74 fns moved into %b over the lifetime of the project. The contract isn't "we'll write these"; it's "we'll delete ours the day stryke ships its own."


&DOT-PATH CONVENTION

Utils::Hash::deep_get / deep_set / deep_has use a dot-path syntax (e.g. "db.pool.max") for nested traversal. Tradeoffs:

ChoiceWhy
Strings, not arrayrefsConfiguration files (YAML/TOML/JSON) read into stryke as nested hashrefs; their access patterns are routinely string-keyed. deep_get(\%cfg, "db.pool.max") reads naturally; deep_get(\%cfg, ["db","pool","max"]) is heavier for the common case.
Dot as separatorMatches Lodash, jq, JSONPath, Vue computed-watcher syntax. Period is the universal nested-access separator outside of Perl/Lisp lineage. Users moving between languages don't have to re-learn.
No escape for literal dotsKeeps the implementation a 5-line split /\./. If a key literally contains a dot, the user wants $h->{"key.with.dots"} directly — deep_get is for shapes that nest, not flat single-key reads.
deep_set autovivifiesMatches $h->{a}{b}{c} = $v behavior in Perl — you'd expect a setter to create intermediate hashes if missing. Returns the modified $h for chaining.

/WHY PURE STRYKE (NO CDYLIB)

Compare against stryke-arrow, which ships a Rust cdylib for the arrow-rs FFI. The cost of a cdylib package:

For stryke-utils, that's all dead weight. parse_duration doesn't get faster in Rust because it's already O(n) in a tiny input and the JSON round-trip would dwarf the work. Same for deep_merge_all, compound_ext, path normalize, etc. Pure stryke means:


#TEST SURFACE

t/test_utils.stk uses stryke's native assertion library (assert_eq, assert_match, assert_ok, assert_gt) and exits TAP-style via test_run. Coverage shape:

SublibAssertion families covered
Stringltrim/rtrim, pad_center alignment, squeeze (consecutive dups), compact_whitespace (normalize-for-hash), visible_width (ANSI-aware), count_occurrences non-overlap, reverse_chars UTF-8 codepoint, partition first-sep split, rpartition last-sep split, mask_middle head/tail preserve, escape_shell single-quote idempotence, expand_tabs per width, unwrap symmetric/asymmetric, ellipsize char-width cap, truncate_words + whitespace normalize, nth_index non-overlap, splitn bounded + remainder, capitalize_first/uncapitalize/titleize, normalize_newlines (CRLF + CR), collapse_blank_lines (whitespace-only blanks), strip_quotes (matched/unmatched/empty), repeat_to (truncate/zero/empty).
Listdifference preserves $a's order, intersection dedups, union uses builtin uniq, windows size-1, size=length, oversize. cartesian a-major order + empty input, rle_encode/rle_decode round-trip + run reset, top_n/bottom_n clamp, zero-n, key-fn scoring.
Hashdeep_merge_all fold (3+ hashes, right-most wins), deep_get hit + miss, deep_set autovivifies, deep_has differentiates exists-undef from missing, map_keys/map_values identity + transform, all_hashes mixed-shape rejection, rename_keys mapped + passthrough, flatten_keys/unflatten_keys deep nesting, empty-hash leaves, round-trip.
Numround_to_multiple (positive + negative step), ordinal teen cases (11th/12th/13th vs 21st/22nd/23rd), digit_sum/digit_count (abs + truncate), pct_of (+ zero-whole dies), mean_abs_dev (symmetric, constant, single, empty dies).
Timeparse_duration multi-unit + ms, ago thresholds at minute/hour/day/week/month/year, ISO-8601 / date / time shape match, timed returns result pair, day_of_week/format_human/format_clock at epoch 0 + noon/midnight, add_duration/sub_duration, parse_iso8601 known epochs + round-trip + garbage rejection.
Pathext (none / single / compound), compound_ext tar.gz, without_ext / set_ext / splitext round-trip, variadic join with slash normalization, path normalize abs + rel + walk-above-root, relative descendant + cousin + same-path, is_absolute/is_bare edge cases, with_name + add_suffix (ext-aware), strip_trailing_slash (keeps root) / ensure_trailing_slash idempotent, segments/depth, is_under (normalizes, rejects sibling-prefix).

%CI GATES

22 shell gate scripts in tests/ are wired into .github/workflows/ci.yml. They lint the repo shape independently of stryke itself — so a CI failure is reproducible by running bash tests/<gate>.sh locally.

BucketGates
repo-contractPure-stryke (no Cargo.toml, no [ffi]). lib/ has exactly 7 files. Every sublib declares package Utils::<Name>. Examples shebang. bin/utils.stk shebang. Makefile has test/install/clean. LICENSE is MIT.
docs gates<h1> present, <body> tag, closing </html>, no deprecated tags (<font>, <center>, ...), no inline event handlers, no placeholder hrefs, no plaintext http://, target="_blank" always paired with rel="noopener noreferrer".
README gatesAt least one shields.io badge, at least one h2 section, at least one https link, ends with final newline.
polish gatesWorkflow files have no tabs (YAML), every tests/*.sh has +x and a bash shebang, man pages (if any) have synopsis section + final newline + no trailing whitespace.

!EXTENDING

Adding a function to an existing sublib means three edits:

  1. Add the fn Utils::<Sub>::name implementation to lib/<Sub>.stk with a leading ## docstring.
  2. Add at least one assert_eq / assert_ok in t/test_utils.stk.
  3. Mention it in the README sublib table and (if user-facing) docs/index.html's sublibrary table.

Adding a new sublib means: drop lib/<Name>.stk with package Utils::<Name>, add use Utils::<Name> to lib/Utils.stk, update Utils::modules, extend the README + docs tables, and bump the file count in tests/repo-contract.sh.


@PROJECT METADATA

ItemValue
LicenseMIT
AuthorMenkeTechnologies
Repositorygithub.com/MenkeTechnologies/stryke-utils
Parent languagestrykelang
Meta umbrellaMenkeTechnologiesMeta
Issuesgithub.com/MenkeTechnologies/stryke-utils/issues