gren-lang / gren-lang/core

firstIndexOf, lastIndexOf and indices return UTF-16 offsets, which no other String function accepts

Open
#148 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
JavaScript
Stars
48
Forks
14
Avg merge
4h 14m
Merged PRs (30d)
1

Description

Found against: gren 0.6.6, gren-lang/core 7.4.2, node 22

Summary

There's a combination of counting Unicode code points and UTF-16 code units in String,
without enough functions to cover all the bases.
Given this:

s = "๐„žab"                      -- U+1D11E, then "a", then "b"

String.count s                 -- 3          (codepoints)
String.unitLength s            -- 4          (code units)

The MUSICAL SYMBOL G CLEF is a single Unicode code point (U+1D11E), but when encoded in UTF-16, it occupies 2 16-bit code units. (0xD834 0xDD1E).

"๐„žab" is 3 Unicode code points (๐„ž, a, and b)
But it has 4 "UTF-16 code units": 0xd834, 0xdd1e, 0x0061, 0x0062

String is character-oriented: count, slice, takeFirst, dropFirst,
reverse, toArray and pad all work in codepoints, and
#130 was fixed by making
_String_slice count codepoints rather than UTF-16 code units.

The three functions that produce an index were not part of that fix.
firstIndexOf, lastIndexOf and indices are String.prototype.indexOf and
lastIndexOf, so they answer in UTF-16 code units โ€” the unit no other
function in the module accepts. For any string with a non-BMP character (i.e., code point >= U+10000) before
the match, an index handed straight back to slice points past where it was
found:

firstIndex returns a count of code units, but slice accepts a count of code poitns

s = "๐„žab"                      -- U+1D11E, then "a", then "b"

String.firstIndexOf "a" s      -- Just 2     (code units; the character is at 1)
String.slice 2 3 s             -- "b"        (codepoints; "a" is at 1)

So the natural composition of firstIndexOf and slice is wrong for strings containing
astral characters (code points >= U+10000) โ€” emoji, mathematical alphanumerics, most historic scripts, the
higher CJK extensions:

when String.firstIndexOf needle haystack is
    Just i ->
        String.slice i (i + String.count needle) haystack   -- not the match
    Nothing ->
        ""

String.indices has the same problem and compounds it: its loop advances by
sub.length, which is also code units, so every offset it returns is shifted by
the number of astral characters before it.

Reproduction

๐„ž is U+1D11E: one codepoint, two UTF-16 units. So in "๐„žab" the character
"a" is at codepoint index 1 and code-unit index 2, and that one-unit
difference is the problem. needleAt below is the natural
composition โ€” find a needle, slice at what was found โ€” and it is the row to
read first.

module Main exposing (probe)


clef : String
clef =
    "๐„žab"


needleAt : String -> String
needleAt needle =
    when String.firstIndexOf needle clef is
        Just i ->
            String.slice i (i + String.count needle) clef

        Nothing ->
            "not found"


showIndex : Maybe Int -> String
showIndex m =
    when m is
        Just n ->
            String.fromInt n

        Nothing ->
            "-"


probe : Array String
probe =
    [ needleAt "a"
    , needleAt "b"
    , needleAt "๐„ž"
    , showIndex (String.firstIndexOf "a" clef)
    , showIndex (String.lastIndexOf "b" clef)
    , String.join "," (Array.map String.fromInt (String.indices "b" clef))
    , String.join "," (Array.map String.fromInt (String.indices "๐„ž" "๐„ža๐„ž"))
    , String.slice 1 2 clef
    , String.fromInt (String.count clef)
    , String.fromInt (String.unitLength clef)
    ]
expression expected, in codepoints actual
needleAt "a" "a" "b"
needleAt "b" "b" "" (index 3 is past the end)
needleAt "๐„ž" "๐„ž" "๐„ž" (index 0, so nothing to shift)
String.firstIndexOf "a" clef Just 1 Just 2
String.lastIndexOf "b" clef Just 2 Just 3
String.indices "b" clef [2] [3]
String.indices "๐„ž" "๐„ža๐„ž" [0,2] [0,3]
String.slice 1 2 clef "a" "a" (consumers are already codepoints)
String.count clef 3 3
String.unitLength clef 4 4 (the one function specified in units)

The first two rows are the failure a caller sees: needleAt "a" returns the
character after the one it found, and needleAt "b" returns the empty string
because the index it was handed is past the end of a three-codepoint string.
Neither reports an error. A string of BMP-only text produces the expected
column throughout, which is why this has gone unnoticed.

Notes

The documentation does not currently say which unit any of the three answers
in. "Unless otherwise noted, all functions in this module deal with code
points" is the module's header, and these three are not noted.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up โ€” it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the String.firstIndexOf, String.lastIndexOf, and String.indices entry points, using the needleAt reproduction with "๐„žab" as the first check. Compare their offsets with String.slice and the codepoint behavior described in the issue. Add coverage for astral characters and verify that returned indices compose correctly with slice and match the expected codepoint offsets.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.