[ 
https://issues.apache.org/jira/browse/GROOVY-12412?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Paul King closed GROOVY-12412.
------------------------------

> Improve consistency of numerous Range indexing edge cases
> ---------------------------------------------------------
>
>                 Key: GROOVY-12412
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12412
>             Project: Groovy
>          Issue Type: Bug
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>              Labels: breaking
>             Fix For: 7.0.0-alpha-1, 6.0.0
>
>
> Range indexing behaves differently depending on the receiver, on which 
> {{Range}} implementation
> the bounds happen to produce, and on whether the range is the only index or 
> one of several. The
> differences are not documented anywhere and no test pins them. This issue 
> brings all of them into
> line and adds the missing tests.
> Six receivers support subscripting by index: {{List}}, {{ListWithDefault}}, 
> object arrays,
> primitive arrays, {{CharSequence}} and {{Matcher}}. Measured over eighteen 
> subscript forms, they
> disagree on twelve of them today.
> h3. The rule that should hold
> A range used as an index has its bounds resolved against the size of the 
> receiver, whether it is
> the only index or one of several: a negative bound counts back from the end, 
> both exclusive forms
> drop the right element, a range written high to low reverses the result, and 
> an empty range
> contributes nothing. Anything that is not a range, including a nested list of 
> indices, is walked
> element by element, with negative values still counting from the end.
> h3. Symptoms
> {code:groovy}
> def list = (1..8).toList()
> def ints = (1..8) as int[]
> def objs = (1..8) as Integer[]
> def m = 'a1b2c3d4' =~ /[a-z]\d/
> // 1. a nested range on a List ignores a negative bound (regression, 5.0.0)
> assert list[0..2, 3..-1] == [1, 2, 3, 4, 5, 6, 7, 8]   // actual [1, 2, 3, 4, 
> 3, 2, 1, 8]
> // 2. a primitive array resolves a sole range only when it is an IntRange
> assert ints[3..-1]   == [4, 5, 6, 7, 8]                // ok
> assert ints[3L..-1L] == [4, 5, 6, 7, 8]                // actual [4, 3, 2, 1, 
> 8]
> assert ints[0, 3..-1] == [1, 4, 5, 6, 7, 8]            // actual [1, 4, 3, 2, 
> 1, 8]
> // 3. a NumberRange cannot tell a negative bound from a reversed range
> assert list[3L..-1L] == [4, 5, 6, 7, 8]                // actual [8, 7, 6, 5, 
> 4]
> assert list[2L..0L]  == [3, 2, 1]                      // actual [1, 2, 3]
> // 4. Matcher loses an element on every exclusive form, ignores reversal,
> //    and rejects a nested collection of indices
> assert m[1..<3] == ['b2', 'c3']                        // actual ['b2']
> assert m[3..1]  == ['d4', 'c3', 'b2']                  // actual ['b2', 'c3', 
> 'd4']
> m[0, [1, 3]]                                           // GroovyCastException
> // 5. a nested empty range adds a stray element on object arrays and strings
> assert objs[0..1, 2..<2]        == [1, 2]              // actual [1, 2, 3]
> assert 'abcdefgh'[0..2, 2..<2]  == 'abc'               // actual 'abcc'
> {code}
> h3. Root causes
> *1. {{DefaultGroovyMethods.getAt(List, Collection)}} lost its dynamic 
> dispatch.* Commit
> 87b4109418 ("minor refactor - no need to funnel through InvokerHelper here", 
> 2022-08-24, no JIRA
> reference) replaced {{InvokerHelper.invokeMethod(self, "getAt", value)}} with 
> a direct call to the
> {{Collection}} overload. The dynamic call let the MOP select {{getAt(List, 
> Range)}} for a nested
> range; the direct call recurses and walks the range's own elements as 
> indices. This is the only
> regression in this issue: it is not on {{GROOVY_4_0_X}} and first shipped in 
> 5.0.0. The sibling
> {{getAt(ListWithDefault, Collection)}} still uses the dynamic form, which is 
> why
> {{ListWithDefault}} was never affected.
> *2. Primitive arrays resolve only an {{IntRange}}.* The sixteen
> {{getAt(prim[], Range<?>)}} and {{getAt(prim[], ObjectRange)}} overloads, and 
> the shared helper
> behind the {{Collection}} overloads, walk the range literally. Only the typed
> {{getAt(prim[], IntRange)}} overloads resolve bounds. Present in every 4.x 
> and 5.x release.
> *3. {{NumberRange.subListBorders}} reads its normalised bounds.* The 
> constructor computes
> {{reverse}} and stores {{from}} and {{to}} in ascending order, so by the time 
> borders are computed
> {{3L..-1L}} is indistinguishable from {{-1L..3L}}. {{IntRange}} keeps the raw 
> order in its fields
> and presents the normalised view through {{getFrom()}} and {{getTo()}}, which 
> is exactly why it
> gets negative indexing right. Both then share {{IntRange.subListBorders}}. 
> Any bound that is not
> an {{Integer}} produces a {{NumberRange}}, so this covers long, short, byte, 
> {{BigInteger}} and
> decimal bounds. Present in every 4.x and 5.x release.
> *4. {{StringGroovyMethods.getAt(Matcher, Collection)}} rebuilds a range from 
> resolved borders.*
> It resolves borders only when the whole index argument is an {{IntRange}}, 
> then does
> {{new IntRange(((IntRange) indices).getInclusive(), info.from, info.to - 
> 1)}}. Re-applying the
> original inclusivity to an already resolved bound drops an element, and 
> {{info.reverse}} is
> discarded. It also has no branch for a nested collection. Present in every 
> 4.x and 5.x release.
> *5. {{EmptyRange}} implements {{Range}}, so a nested empty range reaches
> {{subListBorders(int, Range)}}, which returns a one element window.* This 
> affects object arrays
> and {{CharSequence}}. The {{List}} path avoided it only by accident of the 
> code in cause 1, so the
> guard is needed there too once that is fixed.
> h3. Fix
> || file || change ||
> | {{groovy/lang/NumberRange.java}} | recover the written bound order from the 
> {{reverse}} flag before computing borders |
> | {{runtime/ArrayGroovyMethods.java}} | new private {{primitiveArraySubList}} 
> resolves a range against the array length and handles {{EmptyRange}}; the 
> sixteen sole-index overloads and the nested path both use it |
> | {{runtime/DefaultGroovyMethods.java}} | {{getAt(List, Collection)}} 
> dispatches a nested range to the {{Range}} overload, with an {{EmptyRange}} 
> guard |
> | {{runtime/StringGroovyMethods.java}} | {{EmptyRange}} guard on the 
> {{CharSequence}} path; {{getAt(Matcher, Collection)}} rewritten to read the 
> resolved window and to accept nested collections |
> The eight {{getAt(prim[], IntRange)}} overloads are untouched: they rebuild a 
> range through
> {{subListRange}}, which does correctly undo the inclusivity flags, and they 
> handle an empty window
> correctly.
> The literal walk remains available on every receiver by passing a plain list 
> of indices, for
> example {{a[(3..-1).toList()]}}, which still yields {{[4, 3, 2, 1, 8]}}.
> h3. Behaviour changes that are not regression fixes
> * A {{NumberRange}} index now behaves like the equivalent {{IntRange}} on 
> every receiver. Code
>   relying on {{list[3L..-1L]}} returning a reversed window changes.
> * A sole {{NumberRange}} or {{ObjectRange}} index on a primitive array is now 
> resolved rather than
>   walked.
> * {{matcher[0, [1, 2]]}} now returns a result. {{RegularExpressionsTest}} 
> carried
>   {{shouldFail \{ matcher[0, [1, 2]] \}}}, added in 2008 under GROOVY-3028, 
> which reads as
>   characterising the behaviour of the day rather than arguing the form should 
> be rejected. It has
>   been replaced with a positive assertion, since every other receiver accepts 
> it.
> * The deprecated protected {{primitiveArrayGet}} helpers in 
> {{DefaultGroovyMethods}} still hold
>   the old literal logic. Nothing in Groovy calls them; they are kept for 
> binary compatibility.
> h3. Verification
> * Eighteen subscript forms measured against all six receivers, results 
> normalised to the positions
>   selected: twelve forms disagreed before, none disagree after.
> * Core {{:test}}: 18342 passed, 68 skipped, 0 failed. All 37 module suites: 
> 5691 passed, 0 failed.
> * New tests: {{GroovyMethodsTest.testGetAtMultipleIndicesIncludingRanges}} and
>   {{testGetAtSingleRangeResolvesBoundsOnEveryReceiver}} cover {{List}}, 
> {{ListWithDefault}},
>   {{Integer[]}}, all eight primitive array types and {{CharSequence}} over 
> negative, exclusive,
>   reversed, empty and {{ObjectRange}} bounds in both positions.
>   {{RegularExpressionsTest.testMatcherRangeIndicesMatchOtherAggregates}} does 
> the same for
>   {{Matcher}} and asserts that each index expression selects the same 
> positions as from a list of
>   the same size.
> * New executed javadoc examples on {{getAt(int[], Collection)}}, 
> {{getAt(int[], Range)}} and
>   {{getAt(Matcher, Collection)}}, so the corrected behaviour is documented as 
> well as tested.
> h3. Why this belongs in 6.0.0
> Item 1 alone is a regression against 4.x and would justify a fix on its own. 
> The remaining items
> are long standing, individually obscure, and each looked too marginal to 
> disturb an RC. Taken
> together they are what makes the first item a regression rather than a quirk: 
> the same subscript
> written three ways over the same eight elements returns three different 
> answers, and which answer
> you get depends on whether a bound happened to be an {{int}} or a {{long}}, 
> and on whether the
> range stood alone or sat beside another index. Fixing them as one change is 
> what produces a rule
> that can be stated in a sentence and documented. Deferring the rest to 7 
> would mean shipping 6
> with the regression fixed and the surrounding inconsistencies intact, then 
> changing the same
> methods again a major version later.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to