Skip to content

feat(text, collections): Added missing functions for collections and text modules - #7229

Open
hey-amanthakur wants to merge 19 commits into
denoland:mainfrom
hey-amanthakur:feat/add-common-missing-functions
Open

feat(text, collections): Added missing functions for collections and text modules#7229
hey-amanthakur wants to merge 19 commits into
denoland:mainfrom
hey-amanthakur:feat/add-common-missing-functions

Conversation

@hey-amanthakur

Copy link
Copy Markdown

Added groupBy, shuffle, range, difference, flatten, countBy

  • groupBy(array, keyFn): Groups elements by a key function into a
    Record<K, T[]>. The most requested collection utility across all
    ecosystems (Lodash, Kotlin, etc.).

  • shuffle(array): Returns a new array with elements randomly permuted
    using the Fisher-Yates algorithm. Does not mutate the input.

  • range(startOrEnd, end?, {step?}): Generates a sequence of numbers
    from start (default 0) up to but not including end. Auto-detects
    direction (ascending/descending) when step is omitted.

  • difference(a, b): Returns elements present in array a but not in
    array b. Uses a Set for O(n) lookup performance.

  • flatten(array): Flattens an array of arrays one level deep into a
    single array. Does not perform recursive deep flattening.

  • countBy(array, keyFn): Counts occurrences of each key returned by
    the key function, returning a Record<K, number>.


Added capitalize, stripAnsi, isAlpha, isNumeric, isAlphanumeric, escapeHtml, unescapeHtml

  • capitalize(input): Capitalizes the first character and lowercases
    the rest of the string.

  • stripAnsi(input): Removes ANSI escape codes from terminal strings
    using a comprehensive regex covering SGR, cursor, and control sequences.

  • isAlpha(input): Checks if a string contains only ASCII alphabetic
    characters (A-Z, a-z).

  • isNumeric(input): Checks if a string contains only ASCII numeric
    characters (0-9).

  • isAlphanumeric(input): Checks if a string contains only ASCII
    alphanumeric characters (A-Z, a-z, 0-9).

  • escapeHtml(input): Escapes HTML special characters (&, <, >, ", ')
    using named and numeric character references.

  • unescapeHtml(input): Reverses HTML entity escaping, converting
    named entities and numeric references back to original characters.

hey-amanthakur and others added 17 commits July 10, 2026 21:07
Groups elements of an array by a key returned by the given function.
Returns a Record mapping each key to an array of matching elements.
Returns a new array with elements randomly shuffled using the
Fisher-Yates algorithm. Does not mutate the original array.
Generates an array of numbers progressing from start up to but not
including end, with an optional step value. Supports ascending and
descending ranges via auto-detection or explicit step option.
Returns elements present in the first array but not in the second.
Uses a Set for O(n) lookup performance.
Flattens an array of arrays one level deep into a single array.
Does not perform deep recursive flattening.
Counts occurrences of each key returned by the given function.
Returns a Record mapping each unique key to its count.
Adds 6 new function exports to the collections package:
groupBy, shuffle, range, difference, flatten, countBy
Capitalizes the first character of a string and converts the rest to lowercase.
Removes ANSI escape codes from a string using a comprehensive regex
pattern that covers SGR codes, cursor movements, and other terminal
control sequences.
Checks if a string contains only alphabetic ASCII characters (A-Z, a-z).
Checks if a string contains only numeric ASCII characters (0-9).
Checks if a string contains only alphanumeric ASCII characters (A-Z, a-z, 0-9).
Escapes HTML special characters: & < > " and single quote.
Uses named entities for &, <, >, " and the &denoland#39; numeric entity for single quote.
Reverses HTML entity escaping, converting named entities and numeric
character references back to their original characters.
Adds 7 new function exports to the text package:
capitalize, stripAnsi, isAlpha, isNumeric, isAlphanumeric,
escapeHtml, unescapeHtml
feat(collections): add groupBy, shuffle, range, difference, flatten, countBy
feat(text): add capitalize, stripAnsi, isAlpha, isNumeric, isAlphanumeric, escapeHtml, unescapeHtml
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.01%. Comparing base (ad7c87b) to head (0d81869).

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #7229    +/-   ##
========================================
  Coverage   95.00%   95.01%            
========================================
  Files         617      621     +4     
  Lines       51674    51472   -202     
  Branches     9326     9291    -35     
========================================
- Hits        49093    48905   -188     
+ Misses       2038     2029     -9     
+ Partials      543      538     -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hey-amanthakur hey-amanthakur changed the title Added missing functions for collections and text modules feat(text, collections): Added missing functions for collections and text modules Jul 10, 2026
@babiabeo

babiabeo commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Thanks for drafting this. Here's my feedback:

collections:
- groupBy: superseded by Object.groupBy()
- shuffle: redundant with @std/random/shuffle
- difference: superseded by Set.difference()
- flatten: superseded by Array.flat()

text:
- capitalize: redundant with toPascalCase/toSentenceCase/toTitleCase
- stripAnsi: redundant with @std/fmt/colors/stripAnsiCode
- escapeHtml/unescapeHtml: redundant with @std/html
@hey-amanthakur

Copy link
Copy Markdown
Author

Hi @babiabeo,

Thank you for taking the time to review my PR. I've made the requested changes, could you please take another look when you have a chance?

@bartlomieju

Copy link
Copy Markdown
Member

@tomas-zijdemans thoughts on this one?

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for trimming the original set down in response to @babiabeo's feedback — that was the right instinct.

I've left notes on a few concrete bugs below, mostly in range(). Worth saying that range() is also the function here I'm most convinced by: it's genuinely absent from std and it is not easy to write correctly, which the float-accumulation issue below demonstrates nicely.

Separately from the bugs, two things stand between this and a merge that aren't about code quality:

  1. New APIs need a proposal issue and maintainer sign-off before implementation — I couldn't find one for any of these five. That's on our docs to make more visible, but it does mean the API set needs agreeing before the code matters.
  2. @std/collections (1.3.0) and @std/text (1.0.19) are both stable, so new APIs have to land as unstable_<name>.ts with an @experimental JSDoc tag, and must not be re-exported from mod.ts. There are exemplars to copy in both packages: collections/unstable_cycle.ts and text/unstable_slugify.ts.

None of that is wasted work — it's a relocation, not a rewrite.

Comment thread collections/range.ts

const result: number[] = [];
if (stepValue > 0) {
for (let i = start; i < stop; i += stepValue) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accumulating i += stepValue compounds floating-point error, so fractional steps drift and can produce an extra element. Concretely, range(0, 1, { step: 0.1 }) returns 11 elements ending in 0.9999999999999999, where you'd expect 10 ending at 0.9.

Computing each element from the index avoids it entirely:

const count = Math.max(0, Math.ceil((stop - start) / stepValue));
for (let i = 0; i < count; i++) result.push(start + i * stepValue);

That also collapses both branches into one, since the sign of stepValue is already handled by the arithmetic. Worth a test with step: 0.1 and step: 0.2 — they're the classic float cases.

Comment thread collections/range.ts
if (stepValue === 0) {
throw new RangeError("`step` must not be zero");
}
if (!Number.isFinite(start) || !Number.isFinite(stop)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start and stop are checked for finiteness here, but stepValue never is. range(0, 5, { step: NaN }) silently returns [] (every comparison against NaN is false, so the loop body never runs), and step: Infinity returns a single element.

Silently returning [] is the unkind failure mode — the caller gets no signal at all. Since you already throw a RangeError for step === 0 just above, extending that check to cover non-finite values would be consistent:

Suggested change
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
if (!Number.isFinite(stepValue)) {
throw new RangeError("`step` must be a finite number");
}
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
throw new RangeError("`start` and `end` must be finite numbers");
}

Comment thread text/is_alpha.ts
* ```
*/
export function isAlpha(input: string): boolean {
return /^[A-Za-z]+$/.test(input);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three predicates are ASCII-only, and nothing in the name or the JSDoc says so. isAlpha("café") returns false, isAlpha("日本語") returns false, and isNumeric("-1.5") and isNumeric("١٢٣") likewise — all of which a reader of the name would reasonably expect to be true.

For a standard library shipped to a runtime with first-class Unicode support, silently ASCII-only string predicates are a trap. Two ways out: document the restriction prominently and consider names that carry it, or use Unicode property escapes (/^\p{L}+$/u, /^\p{Nd}+$/u).

Worth deciding before this lands, since it's the kind of semantics that can't be changed afterwards.

@tomas-zijdemans

tomas-zijdemans commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@tomas-zijdemans thoughts on this one?

Whoa! This is a big change 😓 But yeah, let's get into it. I think this needs to be split into multiple PRs.

I agree with everything Bartek flagged, and I'd go one step more conservative: proposal first, code second.

The path I'd suggest:

Open a proposal issue before touching the code further. Of the five, range is the one I'd bet on surviving. It's genuinely missing and hard to write correctly, as the float drift shows. countBy needs a case made against composing Object.groupBy yourself. The three text predicates I'd hold back entirely: ASCII-only semantics under Unicode-sounding names, in a package whose own internals already use \p{L} (see text/_util.ts).

Once something is approved, split into two PRs, one per package. The combined feat(text, collections) title would minor-bump two stable packages in one squash. Split and scoped as feat(collections/unstable): add range, each releases as a patch and each lands (or stalls) on its own merits.

Unstable files, as Bartek said. unstable_range.ts, @experimental tag, not re-exported from mod.ts.

Two more bugs beyond the inline comments, for whenever the code moves. countBy silently loses counts when the key function returns "proto", because the assignment hits the prototype setter and never creates an own property. Accumulating in a Map and returning Object.fromEntries(map) fixes it and deletes the hasOwnProperty branch too. And range's auto-descending default is neither documented nor tested: range(5, 0) walks downward and no test or JSDoc line says so. That one is an API decision, so it belongs in the proposal, not in review comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants