>_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.
~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.
| Sublib | Owns inputs of type | Doesn't own |
|---|---|---|
Utils::String | Scalar 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::List | Arrayrefs — 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::Hash | Hashrefs — 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::Num | Numeric 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::Time | Epoch 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::Path | Filesystem 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.
| Sublib | Count | Functions |
|---|---|---|
Utils::String | 33 | ltrim · 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::List | 20 | difference · 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::Hash | 26 | deep_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::Num | 26 | round_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::Time | 18 | parse_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::Path | 26 | ext · 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.
| Function | Choice |
|---|---|
Time::parse_iso8601 | Converts 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_even | Banker'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_floor | Floored 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_close | Combines 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_of | Walks 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_string | Keys 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_ext | Treats 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).
| Category | Builtins (formerly in this package, now absorbed) |
|---|---|
| String case / slug | trim · slugify · snake_case · kebab_case · camel_case · pascal_case · title_case · swap_case · indent · dedent · rot13 |
| String predicates / distance | contains · starts_with · ends_with · is_blank · is_palindrome · levenshtein · hamming · dice_coefficient · find_all_indices · common_prefix · common_suffix |
| String padding / shaping | pad_left · pad_right · truncate · strip_ansi · word_wrap |
| List | chunk · 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 |
| Hash | deep_merge (binary) · pick · omit · invert · filter · from_pairs/to_pairs · is_empty |
| Num | clamp · between · lerp · round_to · format_number · format_bytes · format_percent · gcd · lcm · sign · is_even/is_odd |
| Time | now_ms/now_us · format_duration · elapsed |
| Path | basename · 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:
| Choice | Why |
|---|---|
| Strings, not arrayrefs | Configuration 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 separator | Matches 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 dots | Keeps 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 autovivifies | Matches $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:
- Per-target release artifacts (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin — minimum four).
- A release pipeline that has to build, sign, and upload each.
- Install-side toolchain (cargo, rustc) when there's no prebuilt for the user's triple.
- A non-trivial unwind across the FFI boundary on every call — allocator, error encoding, JSON serialize / deserialize at the wire.
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:
- One install path, every platform. Tarball with seven
.stkfiles; works on anything stryke runs on. - Same source as runtime. Debug a function by reading the file at the install dir — no decompile, no inspector, no symbol resolution.
- Trivial to fork. Copy a single
lib/*.stkinto a project; it just works as long as its dependencies (only other Utils:: sublibs, never external) come with it. - No release bottleneck. No matrix build, no per-triple QA, no "the linux-arm tarball broke" support load.
#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:
| Sublib | Assertion families covered |
|---|---|
| String | ltrim/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). |
| List | difference 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. |
| Hash | deep_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. |
| Num | round_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). |
| Time | parse_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. |
| Path | ext (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.
| Bucket | Gates |
|---|---|
| repo-contract | Pure-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 gates | At least one shields.io badge, at least one h2 section, at least one https link, ends with final newline. |
| polish gates | Workflow 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:
- Add the
fn Utils::<Sub>::nameimplementation tolib/<Sub>.stkwith a leading##docstring. - Add at least one
assert_eq/assert_okint/test_utils.stk. - 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
| Item | Value |
|---|---|
| License | MIT |
| Author | MenkeTechnologies |
| Repository | github.com/MenkeTechnologies/stryke-utils |
| Parent language | strykelang |
| Meta umbrella | MenkeTechnologiesMeta |
| Issues | github.com/MenkeTechnologies/stryke-utils/issues |